diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.md similarity index 87% rename from .github/agents/agentic-workflows.agent.md rename to .github/agents/agentic-workflows.md index 46256024854..5766bc6263e 100644 --- a/.github/agents/agentic-workflows.agent.md +++ b/.github/agents/agentic-workflows.md @@ -1,5 +1,6 @@ --- -description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing +name: Agentic Workflows +description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing. disable-model-invocation: true --- @@ -13,7 +14,7 @@ This is a **dispatcher agent** that routes your request to the appropriate speci - **Creating new workflows**: Routes to `create` prompt - **Updating existing workflows**: Routes to `update` prompt -- **Debugging workflows**: Routes to `debug` prompt +- **Debugging workflows**: Routes to `debug` prompt - **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt - **Creating report-generating workflows**: Routes to `report` prompt — consult this whenever the workflow posts status updates, audits, analyses, or any structured output as issues, discussions, or comments - **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt @@ -34,7 +35,7 @@ Workflows may optionally include: - Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` - Workflow lock files: `.github/workflows/*.lock.yml` - Shared components: `.github/workflows/shared/*.md` -- Configuration: `.github/aw/github-agentic-workflows.md` +- Configuration: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/github-agentic-workflows.md` ## Problems This Solves @@ -53,123 +54,105 @@ When you interact with this agent, it will: ## Available Prompts -### Create New Workflow +> **Note**: The prompt and reference files listed below are located in the [`github/gh-aw`](https://github.com/github/gh-aw) repository and are **not available locally** in this repository. Load them from their public URLs. +### Create New Workflow **Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet -**Prompt file**: `.github/aw/create-agentic-workflow.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/create-agentic-workflow.md` **Use cases**: - - "Create a workflow that triages issues" - "I need a workflow to label pull requests" - "Design a weekly research automation" -### Update Existing Workflow - +### Update Existing Workflow **Load when**: User wants to modify, improve, or refactor an existing workflow -**Prompt file**: `.github/aw/update-agentic-workflow.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/update-agentic-workflow.md` **Use cases**: - - "Add web-fetch tool to the issue-classifier workflow" - "Update the PR reviewer to use discussions instead of issues" - "Improve the prompt for the weekly-research workflow" -### Debug Workflow - +### Debug Workflow **Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors -**Prompt file**: `.github/aw/debug-agentic-workflow.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/debug-agentic-workflow.md` **Use cases**: - - "Why is this workflow failing?" - "Analyze the logs for workflow X" - "Investigate missing tool calls in run #12345" ### Upgrade Agentic Workflows - **Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations -**Prompt file**: `.github/aw/upgrade-agentic-workflows.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/upgrade-agentic-workflows.md` **Use cases**: - - "Upgrade all workflows to the latest version" - "Fix deprecated fields in workflows" - "Apply breaking changes from the new release" ### Create a Report-Generating Workflow - **Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment -**Prompt file**: `.github/aw/report.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/report.md` **Use cases**: - - "Create a weekly CI health report" - "Post a daily security audit to Discussions" - "Add a status update comment to open PRs" ### Create Shared Agentic Workflow - **Load when**: User wants to create a reusable workflow component or wrap an MCP server -**Prompt file**: `.github/aw/create-shared-agentic-workflow.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/create-shared-agentic-workflow.md` **Use cases**: - - "Create a shared component for Notion integration" - "Wrap the Slack MCP server as a reusable component" - "Design a shared workflow for database queries" ### Fix Dependabot PRs - **Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`) -**Prompt file**: `.github/aw/dependabot.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/dependabot.md` **Use cases**: - - "Fix the open Dependabot PRs for npm dependencies" - "Bundle and close the Dependabot PRs for workflow dependencies" - "Update @playwright/test to fix the Dependabot PR" ### Analyze Test Coverage - **Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy. -**Prompt file**: `.github/aw/test-coverage.md` +**Prompt file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/test-coverage.md` **Use cases**: - - "Create a workflow that comments coverage on PRs" - "Analyze coverage trends over time" - "Add a coverage gate that blocks PRs below a threshold" ### CLI Commands Reference - **Load when**: The user asks how to run, compile, debug, or manage workflows from the command line; needs the MCP tool equivalent of a `gh aw` command; or is in a restricted environment (e.g., Copilot Cloud) without direct CLI access. -**Reference file**: `.github/aw/cli-commands.md` +**Reference file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/cli-commands.md` **Use cases**: - - "How do I trigger workflow X on the main branch?" - "What's the MCP equivalent of `gh aw logs`?" - "I'm in Copilot Cloud — how do I compile a workflow?" - "Show me all available gh aw commands" ### Token Consumption Optimization - **Load when**: The user asks how to reduce token usage, lower workflow costs, make a workflow faster or cheaper, or measure the impact of prompt or configuration changes. -**Reference file**: `.github/aw/token-optimization.md` +**Reference file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/token-optimization.md` **Use cases**: - - "How do I reduce the token cost of this workflow?" - "My workflow is too expensive — how do I optimize it?" - "How do I compare token usage between two runs?" @@ -178,13 +161,11 @@ When you interact with this agent, it will: - "How do I measure the impact of a prompt change?" ### Workflow Pattern Selection - **Load when**: The user asks for architecture, strategy, operating model selection, or pattern recommendations for building agentic workflows. -**Reference file**: `.github/aw/patterns.md` +**Reference file**: `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/patterns.md` **Use cases**: - - "Which pattern should I use for multi-repo rollout?" - "How should I structure this workflow architecture?" - "What pattern fits slash-command triage?" @@ -195,7 +176,7 @@ When you interact with this agent, it will: When a user interacts with you: 1. **Identify the task type** from the user's request -2. **Load the appropriate prompt** from the repository paths listed above +2. **Load the appropriate prompt** from the URLs listed above 3. **Follow the loaded prompt's instructions** exactly 4. **If uncertain**, ask clarifying questions to determine the right prompt @@ -234,15 +215,15 @@ gh aw compile --validate ## Important Notes -- Always reference the instructions file at `.github/aw/github-agentic-workflows.md` for complete documentation +- Always reference the instructions file at `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/github-agentic-workflows.md` for complete documentation - Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud - Workflows must be compiled to `.lock.yml` files before running in GitHub Actions - **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF - Follow security best practices: minimal permissions, explicit network access, no template injection -- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See `.github/aw/network.md` for the full list of valid ecosystem identifiers and domain patterns. +- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/network.md` for the full list of valid ecosystem identifiers and domain patterns. - **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself. - **Triggering runs**: Always use `gh aw run ` to trigger a workflow on demand — not `gh workflow run .lock.yml`. `gh aw run` handles workflow resolution by short name, input parsing and validation, and correct run-tracking for agentic workflows. Use `--ref ` to run on a specific branch. -- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see `.github/aw/cli-commands.md` +- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see `https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/cli-commands.md` - **Repository-specific requirements**: There are multiple sets of repository-specific instructions below that must be respected. They affect workflow authoring, updates, compilation, and reviews. ## Repository-Specific Requirements: Copilot PAT Pool diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 2ecec2fa05b..87a3edeb338 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,9 +1,9 @@ { "entries": { - "actions/checkout@v6.0.3": { + "actions/checkout@v7.0.0": { "repo": "actions/checkout", - "version": "v6.0.3", - "sha": "df4cb1c069e1874edd31b4311f1884172cec0e10" + "version": "v7.0.0", + "sha": "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" }, "actions/download-artifact@v8.0.1": { "repo": "actions/download-artifact", @@ -30,15 +30,15 @@ "version": "v7.0.1", "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" }, - "github/gh-aw-actions/setup-cli@v0.79.8": { + "github/gh-aw-actions/setup-cli@v0.80.9": { "repo": "github/gh-aw-actions/setup-cli", - "version": "v0.79.8", - "sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47" + "version": "v0.80.9", + "sha": "8c7d04ebf1ece56cd381446125da3e0f6896294a" }, - "github/gh-aw-actions/setup@v0.79.8": { + "github/gh-aw-actions/setup@v0.80.9": { "repo": "github/gh-aw-actions/setup", - "version": "v0.79.8", - "sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47" + "version": "v0.80.9", + "sha": "8c7d04ebf1ece56cd381446125da3e0f6896294a" } } } diff --git a/.github/scripts/api-diff-discover.cs b/.github/scripts/api-diff-discover.cs new file mode 100644 index 00000000000..1b811e4d408 --- /dev/null +++ b/.github/scripts/api-diff-discover.cs @@ -0,0 +1,335 @@ +#!/usr/bin/env dotnet +#:package NuGet.Protocol@6.12.1 +#:property NuGetAudit=false +#:property NoWarn=IL2026;IL3050 +#:property JsonSerializerIsReflectionEnabledByDefault=true + +// Deterministic discovery for the API Diff Orchestrator, driven entirely by the dnceng +// NuGet feeds. Emits the set of API diff reports to produce for the +// active, not-yet-shipped majors. +// +// Majors: maxGA is the highest major with a GA (X.0.0, non-prerelease) build on +// dotnet-public. We only ever consider maxGA+1 and maxGA+2 -- maxGA+3's baseline +// (maxGA+2) could not yet be at RC, so it would always be held off. +// +// Two tracks per active major: +// +// - major-to-major (cumulative): (X-1) -> X head. The CURRENT build is the newest +// build on the dotnet{X} channel feed regardless of prerelease label (alpha, +// beta, preview, rc) so the cumulative "what's coming in X" surface tracks the +// freshest bits. The BASELINE is the newest (X-1) build on dotnet-public that is +// RC-or-GA (RC is public and API-locked, so an RC baseline yields materially the +// same diff as GA; it advances RC1 -> RC2 -> GA as (X-1) finalizes). The PR +// identity stays canonical "(X-1).0-ga_to_X.0"; only the pinned previous_version +// evolves. Content lives at the major root: release-notes/X.0/X.0.0/api-diff. +// +// - incremental (milestone deltas): the CURRENT end is the latest NAMED milestone (preview.N / +// rc.N) on the dotnet{X} dev feed, so a milestone's DRAFT api-diff PR is produced as soon as +// it has builds -- to be reviewed and merged BEFORE it releases publicly. The BASELINE is the +// previous milestone's SHIPPED build on dotnet-public; the diff is HELD OFF until that +// predecessor releases (e.g. preview.7 waits for preview.6 to ship), so the "before" side is +// always a stable released milestone. preview.1's baseline is the prior major's shipped +// RC-or-GA. Reports are milestone-named, so advancing dev builds are content-only refreshes. +// Content lives under release-notes/X.0/preview//api-diff. +// +// Status: maxGA+1 is code-complete (PR Ready for Review) once maxGA+2 has started +// publishing builds (main has forked to the next major); otherwise in-development +// (PR stays draft). maxGA+2 is always in-development. +// +// Feed version discovery uses the NuGet client SDK (NuGet.Protocol + +// NuGet.Versioning) for correct SemVer ordering. When an output-file path is passed +// as the first argument the JSON array is written there (so the "dotnet run" build +// output on stdout never contaminates the capture); otherwise it is written to +// stdout. Notices/warnings/errors go to stderr as GitHub Actions workflow commands. + +using System.Text.Json; +using System.Text.RegularExpressions; +using NuGet.Common; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +const string PublicChannel = "dotnet-public"; +const string RefPackage = "Microsoft.NETCore.App.Ref"; + +static void Notice(string m) => Console.Error.WriteLine($"::notice::{m}"); +static void Warn(string m) => Console.Error.WriteLine($"::warning::{m}"); +static void Fail(string m) +{ + Console.Error.WriteLine($"::error::{m}"); + Environment.Exit(1); +} + +// ---- NuGet feed discovery -------------------------------------------------- +static string FeedIndex(string channel) => + $"https://pkgs.dev.azure.com/dnceng/public/_packaging/{channel}/nuget/v3/index.json"; + +var versionsCache = new Dictionary>(); + +async Task> VersionsOn(string channel, bool quiet = false) +{ + if (versionsCache.TryGetValue(channel, out var cached)) + return cached; + + // A transient feed error (or a channel that does not exist yet) throws; retry a + // few times before treating the channel as having no builds. NoCache so a + // just-published build is seen immediately. + IReadOnlyList result = []; + Exception? last = null; + var cache = new SourceCacheContext { NoCache = true }; + var repo = NuGet.Protocol.Core.Types.Repository.Factory.GetCoreV3(FeedIndex(channel)); + for (var attempt = 1; attempt <= 3; attempt++) + { + try + { + var resource = await repo.GetResourceAsync(); + var versions = await resource.GetAllVersionsAsync( + RefPackage, cache, NullLogger.Instance, CancellationToken.None); + result = versions.ToList(); + last = null; + break; + } + catch (Exception ex) + { + last = ex; + if (attempt < 3) + await Task.Delay(2000); + } + } + if (last is not null) + { + // The maxGA+2 channel legitimately does not exist until main forks to it, so + // its probe is quiet (a Notice); a feed we expect to exist warns. + var msg = $"feed query for '{channel}' failed after 3 attempts: {last.Message}"; + if (quiet) + Notice($"{channel}: no builds (channel not present yet)."); + else + Warn(msg); + } + + versionsCache[channel] = result; + return result; +} + +// All X.0.0[-*] ref-pack versions on a channel (the .0 release line for major X). +async Task> ReleaseLine(int major, string channel) => + (await VersionsOn(channel)) + .Where(v => v.Major == major && v.Minor == 0 && v.Patch == 0) + .ToList(); + +// Newest X build across the given channels, regardless of prerelease label. Ties +// keep the first channel listed (prefer the per-major dev feed over dotnet-public). +async Task<(NuGetVersion Ver, string Feed)?> LatestAnyLabel(int major, params string[] channels) +{ + (NuGetVersion Ver, string Feed)? best = null; + foreach (var channel in channels) + foreach (var v in await ReleaseLine(major, channel)) + if (best is null || VersionComparer.Default.Compare(v, best.Value.Ver) > 0) + best = (v, channel); + return best; +} + +// Newest (major).0.0 build on dotnet-public that is RC-or-GA: prefer GA (the final, +// non-prerelease X.0.0), else the newest rc.N. Null until (major) reaches RC. +async Task LatestRcOrGa(int major) +{ + var line = await ReleaseLine(major, PublicChannel); + var ga = line.Where(v => !v.IsPrerelease) + .OrderBy(v => v, VersionComparer.Default).LastOrDefault(); + if (ga is not null) + return ga; + return line + .Where(v => v.IsPrerelease && v.Release.StartsWith("rc.", StringComparison.OrdinalIgnoreCase)) + .OrderBy(v => v, VersionComparer.Default).LastOrDefault(); +} + +// Highest milestone of a kind ("preview"/"rc") for major on channel: (N, newest build). +async Task<(int Num, NuGetVersion Ver)?> MaxMilestone(int major, string kind, string channel) +{ + var rx = new Regex($@"^{Regex.Escape(kind)}\.(\d+)(\.|$)", RegexOptions.IgnoreCase); + var matches = (await ReleaseLine(major, channel)) + .Where(v => v.IsPrerelease && rx.IsMatch(v.Release)) + .ToList(); + if (matches.Count == 0) + return null; + var maxN = matches.Max(v => int.Parse(rx.Match(v.Release).Groups[1].Value)); + var ver = matches + .Where(v => int.Parse(rx.Match(v.Release).Groups[1].Value) == maxN) + .OrderBy(v => v, VersionComparer.Default).Last(); + return (maxN, ver); +} + +// Newest build of a specific milestone (kind.num) for major on channel, or null. +async Task MilestoneBuild(int major, string kind, int num, string channel) +{ + var rx = new Regex($@"^{Regex.Escape(kind)}\.{num}(\.|$)", RegexOptions.IgnoreCase); + return (await ReleaseLine(major, channel)) + .Where(v => v.IsPrerelease && rx.IsMatch(v.Release)) + .OrderBy(v => v, VersionComparer.Default).LastOrDefault(); +} + +// Latest NAMED milestone (rc preferred over preview) on the major's dev feed. Uses the +// dotnet{major} dev channel -- not dotnet-public -- so a milestone gets its draft api-diff PR +// as soon as it has builds, letting the diff be reviewed and merged BEFORE the milestone +// releases publicly (a public-only source would surface it too late). +async Task<(string Kind, int Num, NuGetVersion Ver)?> LatestMilestone(int major) +{ + var channel = $"dotnet{major}"; + if (await MaxMilestone(major, "rc", channel) is { } rc) + return ("rc", rc.Num, rc.Ver); + if (await MaxMilestone(major, "preview", channel) is { } pv) + return ("preview", pv.Num, pv.Ver); + return null; +} + +// The milestone label of a version: "preview.7", "rc.2", "alpha.1", ... or "ga". +// Normalized to lower case so an upper/mixed-case feed label (e.g. "RC.2") yields a +// canonical marker and folder name, consistent with RunApiDiff.ps1's label handling. +static string HeadLabel(NuGetVersion v) +{ + if (!v.IsPrerelease) + return "ga"; + var parts = v.Release.ToLowerInvariant().Split('.'); + return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}" : parts[0]; +} + +static string Undot(string label) => label.Replace(".", ""); + +// The incremental baseline (previous milestone) for at head milestone (kind.num), as +// (version-milestone, version, feed). The baseline is the previous milestone's SHIPPED build on +// dotnet-public: a milestone diff always pins its "before" to a released milestone, so the diff +// is HELD OFF until the previous milestone ships (e.g. preview.7 waits for preview.6 to release). +// preview.1's baseline is the prior major's shipped RC-or-GA on dotnet-public. +async Task<(string VersionMilestone, string Version, string Feed)?> PrevMilestone(int major, string kind, int num) +{ + if (kind == "preview" && num > 1) + { + var v = await MilestoneBuild(major, "preview", num - 1, PublicChannel); + return v is null ? null : ($"{major}.0-preview.{num - 1}", v.ToNormalizedString(), PublicChannel); + } + if (kind == "rc" && num > 1) + { + var v = await MilestoneBuild(major, "rc", num - 1, PublicChannel); + return v is null ? null : ($"{major}.0-rc.{num - 1}", v.ToNormalizedString(), PublicChannel); + } + if (kind == "rc") + { + // rc.1: baseline is the highest shipped public preview of this major. + var pv = await MaxMilestone(major, "preview", PublicChannel); + return pv is null ? null : ($"{major}.0-preview.{pv.Value.Num}", pv.Value.Ver.ToNormalizedString(), PublicChannel); + } + + // preview.1 (first milestone of the major): baseline is the prior major's shipped + // RC-or-GA. The label stays canonical "(X-1).0-ga" even while the pin is an RC. + var prior = await LatestRcOrGa(major - 1); + return prior is null ? null : ($"{major - 1}.0-ga", prior.ToNormalizedString(), PublicChannel); +} + +// ---- Determine the active majors from the feeds ---------------------------- +var publicVersions = await VersionsOn(PublicChannel); +var maxGa = publicVersions.Where(v => !v.IsPrerelease).Select(v => v.Major).DefaultIfEmpty(0).Max(); +if (maxGa == 0) + Fail($"Could not determine any GA major of {RefPackage} on {PublicChannel}. Aborting instead of emitting an empty target set."); +Notice($"maxGA = {maxGa}.0"); + +// maxGA+2 activity (main has forked to the next major) makes maxGA+1 code-complete. +// Prime the maxGA+2 channel cache quietly first, since it does not exist until main +// forks to it and should not warn on every run. +var vNextMajor = maxGa + 2; +await VersionsOn($"dotnet{vNextMajor}", quiet: true); +var vNextHead = await LatestAnyLabel(vNextMajor, $"dotnet{vNextMajor}", PublicChannel); +var vNextActive = vNextHead is not null; + +// ---- Emit targets ---------------------------------------------------------- +var targets = new List(); +foreach (var major in new[] { maxGa + 1, maxGa + 2 }) +{ + var channel = $"dotnet{major}"; + var head = major == vNextMajor ? vNextHead : await LatestAnyLabel(major, channel, PublicChannel); + if (head is null) + { + Notice($"{major}.0: no builds on the feeds yet; skipping."); + continue; + } + var headVer = head.Value.Ver; + var headLabel = HeadLabel(headVer); + var headUndotted = Undot(headLabel); + var status = major == vNextMajor + ? "in-development" + : (vNextActive ? "code-complete" : "in-development"); + Notice($"{major}.0 head -> {headVer.ToNormalizedString()} ({head.Value.Feed}) [{headLabel}], status={status}"); + + // major-to-major (cumulative): (X-1) RC-or-GA -> X head (any label). + var baseline = await LatestRcOrGa(major - 1); + if (baseline is not null) + { + var marker = $"{major - 1}.0-ga_to_{major}.0"; + targets.Add(new Target( + track: "major-to-major", major: $"{major}.0", + previous_version_milestone: $"{major - 1}.0-ga", current_version_milestone: $"{major}.0-{headLabel}", + previous_version: baseline.ToNormalizedString(), previous_feed: FeedIndex(PublicChannel), + current_version: headVer.ToNormalizedString(), current_feed: FeedIndex(head.Value.Feed), + content_dir: $"release-notes/{major}.0/{major}.0.0/api-diff", cur_milestone: headUndotted, + marker_id: marker, desired_branch: $"api-diff/{marker}", + status: status)); + } + else + { + Notice($"{major}.0 cumulative held off: {major - 1}.0 is not yet at RC/GA on {PublicChannel}."); + } + + // incremental (milestone deltas): previous milestone -> latest milestone, sourced from the + // dotnet{major} dev feed so a draft PR is produced as soon as a milestone has builds (before + // it releases publicly). + var ms = await LatestMilestone(major); + if (ms is not null) + { + var (kind, num, msVer) = ms.Value; + var msLabel = $"{kind}.{num}"; + var pm = await PrevMilestone(major, kind, num); + if (pm is not null) + { + var currentVersionMilestone = $"{major}.0-{msLabel}"; + var marker = $"{pm.Value.VersionMilestone}_to_{currentVersionMilestone}"; + targets.Add(new Target( + track: "incremental", major: $"{major}.0", + previous_version_milestone: pm.Value.VersionMilestone, current_version_milestone: currentVersionMilestone, + previous_version: pm.Value.Version, previous_feed: FeedIndex(pm.Value.Feed), + current_version: msVer.ToNormalizedString(), current_feed: FeedIndex($"dotnet{major}"), + content_dir: $"release-notes/{major}.0/preview/{Undot(msLabel)}/api-diff", cur_milestone: Undot(msLabel), + marker_id: marker, desired_branch: $"api-diff/{marker}", + status: status)); + } + else + { + Notice($"{major}.0 incremental ({msLabel}) held off: the previous milestone has not shipped to {PublicChannel} yet."); + } + } +} + +var output = targets + .GroupBy(t => (t.track, t.desired_branch)) + .Select(g => g.First()) + .OrderBy(t => t.major, StringComparer.Ordinal) + .ThenBy(t => t.track, StringComparer.Ordinal) + .ThenBy(t => t.current_version_milestone, StringComparer.Ordinal) + .ToList(); + +var json = JsonSerializer.Serialize(output); + +// When an output path is passed, write the JSON there so the caller can read a +// clean file regardless of any build/restore output "dotnet run" emits to stdout; +// otherwise emit to stdout for local/ad-hoc use. +if (args.Length > 0) + File.WriteAllText(args[0], json); +else + Console.WriteLine(json); + +// Field names/order mirror the JSON contract the API Diff Orchestrator consumes. +record Target( + string track, string major, string previous_version_milestone, string current_version_milestone, + string previous_version, string previous_feed, + string current_version, string current_feed, + string content_dir, string cur_milestone, + string marker_id, string desired_branch, + string status); diff --git a/.github/scripts/api-diff-generate.sh b/.github/scripts/api-diff-generate.sh new file mode 100755 index 00000000000..b7579b4fdf9 --- /dev/null +++ b/.github/scripts/api-diff-generate.sh @@ -0,0 +1,622 @@ +#!/usr/bin/env bash +# api-diff-generate.sh +# +# Host-side generation for the API Diff Worker workflow. Given a single discovery target it positions +# the working tree on the branch the agent will publish to (detached at the base for a fresh +# diff, or the maintained PR branch for an incremental update) and generates the milestone's +# API diff reports from real published builds, then writes the agent context. Heavy/networked +# work happens here (pwsh + feed access) so the agent only commits the result and publishes it +# through the native safe outputs. Reviewer feedback is collected by the agent itself under +# gh-aw integrity filtering, not here. +# +# Ref packs come from the feeds the target pins: the current build from the +# dotnet{major} channel feed, and the baseline (previous prerelease milestone or +# previous-major GA) from dotnet-public. The ApiDiff tool installs from +# dotnet{major}-transport. +# +# Exclusions (apidiff file format: attributes "T:" per line; +# assemblies bare name per line): +# - permanent attributes : release-notes/ApiDiffAttributesToExclude.txt +# - temporary attributes : /ApiDiffAttributesToExclude.txt (per report) +# - permanent assemblies : release-notes/ApiDiffAssembliesToExclude.txt (no temporary) +# The permanent + temporary attribute lists are merged into one file passed to the +# tool (the CLI takes a single -eattrs file); the global assemblies file is passed +# as -eas. Pending permanent edits on the PR branch are honored before merge. +# +# Output: +# incremental -> release-notes//preview//api-diff +# major-to-major -> release-notes//.0/api-diff (relocated from the tool's +# preview folder when the current endpoint is a prerelease) +# +# Gates (in priority order): +# BLOCKED : a non-automation `api-diff` PR already introduces reports under +# this target's content_dir -> a human owns this diff; stand down +# (produce=false, open/disturb nothing). +# FAST no-op : an existing PR whose recorded current_version AND status are +# unchanged and that has no review activity newer than the recorded +# generated_at watermark skips all work (noop=true). +# METADATA-only: build unchanged but status flipped (draft->ready) and/or new review +# activity arrived -> refresh PR body/ready/exclusions WITHOUT the +# expensive full regeneration. +# COMPLETE-or-nothing: a full regeneration must produce every non-excluded SDK +# report (NETCore + AspNetCore + WindowsDesktop). A non-zero +# RunApiDiff exit or any missing SDK report -> produce=false; we +# never publish a partial diff, and never disturb an existing PR. +# +# Inputs: +# TARGET required single discovery target (JSON) from api-diff-discover.cs +# AGENT_DIR optional where to write target.json (default /tmp/gh-aw/agent) +# CONTENT_ROOT optional release-notes content root (default release-notes) +# Environment: +# API_DIFF_TIMEOUT optional seconds for the diff (default 1800) +# GH_TOKEN optional PR lookup +# GH_RUN_ID optional workflow run id for the branch collision suffix +# GITHUB_REPOSITORY optional owner/repo for PR/comment lookups +set -euo pipefail + +TARGET="${TARGET:?TARGET (discovery JSON) is required}" +AGENT_DIR="${AGENT_DIR:-/tmp/gh-aw/agent}" +CONTENT_ROOT="${CONTENT_ROOT:-release-notes}" +REPO="${GITHUB_REPOSITORY:-}" +mkdir -p "$AGENT_DIR" + +# Capture the feedback watermark at the START of the run, not the end. Generation can +# take many minutes, and any review activity that arrives while it runs must be picked +# up by a later run rather than being stamped as already-processed. The agent stamps +# this value back into the PR body's yaml block as the new generated_at; the next run +# reads it as the lower bound for which review feedback is new (run_started_at). +generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +jqr() { jq -r "$1" <<<"$TARGET"; } +track="$(jqr '.track')" +major="$(jqr '.major')" +previous_version_milestone="$(jqr '.previous_version_milestone')" +current_version_milestone="$(jqr '.current_version_milestone')" +previous_version="$(jqr '.previous_version')" +previous_feed="$(jqr '.previous_feed')" +current_version="$(jqr '.current_version')" +current_feed="$(jqr '.current_feed')" +content_dir="$(jqr '.content_dir')" +cur_milestone="$(jqr '.cur_milestone')" +desired_branch="$(jqr '.desired_branch')" +status="$(jqr '.status')" +marker_id="$(jqr '.marker_id')" + +# content_dir drives destructive filesystem ops (mkdir -p, and rm -rf + mv during the +# major-to-major relocation). It is machine-generated by the discovery step, but validate +# its shape before any rm -rf so a malformed or empty value can never escape the intended +# report directory: require a relative path, no '..' traversal, ending in the '/api-diff' +# leaf that discovery always emits. Fail loudly rather than risk deleting the wrong tree. +case "$content_dir" in +"" | /* | *..*) + echo "::error::content_dir '${content_dir}' must be a non-empty relative path without '..'; aborting before any filesystem op." + exit 1 + ;; +*/api-diff) ;; +*) + echo "::error::content_dir '${content_dir}' does not end in '/api-diff'; aborting before any filesystem op." + exit 1 + ;; +esac + +# The identity marker lives as a VISIBLE yaml comment inside the PR body's state +# block -- never an HTML comment, since hidden markers trip content scanners. It is +# the stable, machine-readable handle used to find this diff's PR across runs. For +# the cumulative major-to-major diff the marker_id is keyed on the major only, so a +# single evergreen PR is reused as the head milestone advances. +marker="api-diff-marker: ${marker_id}" +perm_attrs="${CONTENT_ROOT}/ApiDiffAttributesToExclude.txt" +perm_asms="${CONTENT_ROOT}/ApiDiffAssembliesToExclude.txt" +temp_attrs="${content_dir}/ApiDiffAttributesToExclude.txt" + +# human_version_milestone -> ".NET 11 Preview 6" / ".NET 10" / ".NET 11 RC 1" / ".NET 11 Alpha 1" +human_version_milestone() { + local version_milestone="$1" maj ms + maj="${version_milestone%%-*}" + maj="${maj%.*}" + ms="${version_milestone#*-}" + case "$ms" in + ga) echo ".NET ${maj}" ;; + alpha.*) echo ".NET ${maj} Alpha ${ms#alpha.}" ;; + beta.*) echo ".NET ${maj} Beta ${ms#beta.}" ;; + preview.*) echo ".NET ${maj} Preview ${ms#preview.}" ;; + rc.*) echo ".NET ${maj} RC ${ms#rc.}" ;; + *) echo ".NET ${maj} ${ms}" ;; + esac +} +prev_human="$(human_version_milestone "$previous_version_milestone")" +cur_human="$(human_version_milestone "$current_version_milestone")" +if [ "$track" = "major-to-major" ]; then + pr_title="API diff between .NET ${previous_version_milestone%%.*} and .NET ${major%.*}" +else + pr_title="API diff between ${prev_human} and ${cur_human}" +fi + +# ---- 0. Resolve the existing marker PR + external-PR guard ------------------ +# Our automation PRs carry the `automation` + `api-diff` labels; every hand-authored +# API diff PR carries `api-diff` (with or without `automation`). One labeled query +# finds them all, and we resolve this target's PR in priority order: +# (a) OUR PR, matched by its identity marker in the body -> reuse it; +# (b) a NON-automation `api-diff` PR already introducing reports under this +# target's content_dir -> a human owns this diff and we stand down (blocked). +existing_pr_number="" +target_branch="$desired_branch" +blocked=false +pr_list_failed=false +if [ -n "${GH_TOKEN:-}" ]; then + # Candidate PRs: the labeled set, unioned with any PR already on our target branch. + # Identity is the body marker, not the label -- so if a human strips the `api-diff` + # label from our PR we must still find it (by branch) rather than open a duplicate. + # Restrict to PRs targeting main -- our automation only ever opens PRs against main, + # so a PR from the same head/label that targets a non-main base must never be mistaken + # for the maintained PR. Filter on --base/base: at the API and re-assert baseRefName in + # jq so a loose search qualifier can never leak a non-main-base PR into the candidates. + # Fail closed on a discovery-query failure. The old `|| echo '[]'` made a transient + # list failure indistinguishable from a genuine empty result; combined with the + # collision-branch fallback below, that could open a DUPLICATE PR when the target + # branch already exists. Capture each query's exit status instead and stand down + # (noop) below when either failed, so nothing is created/updated off a partial read. + if ! api_diff_prs="$(gh pr list --state open --base main --label api-diff --limit 200 --json number,headRefName,baseRefName,body,labels 2>/dev/null)"; then + pr_list_failed=true + fi + if ! by_branch="$(gh pr list --state open --search "head:$desired_branch base:main" --limit 50 --json number,headRefName,baseRefName,body,labels 2>/dev/null)"; then + pr_list_failed=true + fi + if [ "$pr_list_failed" = true ]; then + api_diff_prs='[]' + by_branch='[]' + echo "::warning::PR discovery query failed for ${desired_branch}; standing down (no PR opened/updated this run, retried next run)." + fi + api_diff_prs="$(printf '%s\n%s\n' "$api_diff_prs" "$by_branch" | jq -s 'add | map(select((.baseRefName // "") == "main")) | unique_by(.number)' 2>/dev/null || echo "$api_diff_prs")" + # Match the identity marker as a WHOLE yaml-comment line ("# "), not a bare + # substring: a substring match would let the cumulative marker "...-ga_to_11.0" reuse + # an incremental first-preview PR whose marker is "...-ga_to_11.0-preview.1". Strip CR + # (GitHub bodies are CRLF) and trailing whitespace before the exact-line comparison. + match="$(jq -c --arg m "$marker" 'map(select(.body != null and ((.body | gsub("\r";"") | split("\n") | any(gsub("^[ \t]+|[ \t]+$";"") == "# " + $m))))) | .[0] // empty' <<<"$api_diff_prs")" + if [ -n "$match" ]; then + existing_pr_number="$(jq -r '.number' <<<"$match")" + target_branch="$(jq -r '.headRefName' <<<"$match")" + echo "::notice::reusing marker PR #${existing_pr_number} on ${target_branch}" + else + # (b) Adopt a marker-less PR for this target so a human can hand-start a PR the + # automation then takes over. A body carrying any `api-diff-marker:` already belongs + # to some target and is never adopted. Our exact target branch is proof of ownership, + # so adopt a marker-less PR on it regardless of labels (a human may have stripped them + # or bootstrapped it there). On any OTHER branch, only adopt a PR a human explicitly + # opted into automation for (both `automation` + `api-diff`) that already introduces + # reports under this target's content_dir (confirmed per-PR via `gh pr view` to stay + # truncation-safe, like the external-PR guard below). + adopt_branch="$(jq -r --arg br "$desired_branch" \ + 'map(select(((.body // "") | contains("api-diff-marker:") | not) and (.headRefName == $br))) | (.[0].number // empty)' <<<"$api_diff_prs")" + if [ -n "$adopt_branch" ]; then + existing_pr_number="$adopt_branch" + target_branch="$desired_branch" + echo "::notice::adopting marker-less PR #${existing_pr_number} on target branch ${target_branch} (marker will be written on this refresh)." + else + adopt_cands="$(jq -r --arg br "$desired_branch" \ + 'map(select(((.body // "") | contains("api-diff-marker:") | not) and (.headRefName != $br) and ([.labels[].name] | index("automation")) and ([.labels[].name] | index("api-diff")))) | .[].number' <<<"$api_diff_prs")" + for acand in $adopt_cands; do + if gh pr view "$acand" --json files --jq '.files[].path' 2>/dev/null | + awk -v p="${content_dir%/}/" 'index($0, p) == 1 { found = 1 } END { exit !found }'; then + existing_pr_number="$acand" + target_branch="$(jq -r --argjson n "$acand" 'map(select(.number == $n)) | .[0].headRefName // empty' <<<"$api_diff_prs")" + echo "::notice::adopting automation PR #${existing_pr_number} on ${target_branch} (no marker yet; introduces reports under ${content_dir})." + break + fi + done + fi + fi + # (c) When we neither reused nor adopted a PR: pick a collision branch if the desired + # branch is already taken, then stand down if a non-automation api-diff PR owns this diff. + if [ -z "$existing_pr_number" ] && [ "$blocked" = false ]; then + if git ls-remote --exit-code --heads "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$desired_branch" >/dev/null 2>&1; then + target_branch="${desired_branch}_${GH_RUN_ID:-$(date +%s)}" + echo "::notice::${desired_branch} already exists; using collision branch ${target_branch}" + fi + # External-PR guard: only when WE don't already own or adopt a PR for this diff. + # A human-authored api-diff PR might touch >100 files, in which case the GraphQL/REST API + # truncates the `files` array on `gh pr list`. We must fetch the file list via `gh pr view` + # for each non-automation candidate to accurately detect collisions. + human_prs="$(jq -r 'map(select(([.labels[].name] | index("automation")) | not)) | .[].number' <<<"$api_diff_prs")" + for hpr in $human_prs; do + # Check if this PR touches the target directory. Use awk index()==1 for a + # literal, start-anchored prefix match: content_dir contains "." (e.g. + # release-notes/11.0/...) which a grep regex would treat as a wildcard. + if gh pr view "$hpr" --json files --jq '.files[].path' 2>/dev/null | + awk -v p="${content_dir%/}/" 'index($0, p) == 1 { found = 1 } END { exit !found }'; then + blocked=true + echo "::notice::api-diff PR #${hpr} (non-automation) already introduces reports under ${content_dir}; standing down." + break + fi + done + fi +fi + +# ---- 1. No-op / metadata-only decision ------------------------------------- +# Compare against the state the last successful run recorded in the PR body's yaml +# block: current_version, status, generated_at (the feedback watermark), and whether +# the resolved PR still carries this target's marker. +noop=false +metadata_only=false +has_new_feedback=false +watermark="" +if [ "$pr_list_failed" = true ]; then + # Discovery failed above; we cannot know whether a PR already exists, so stand down + # without opening or refreshing anything (generated_at is not advanced; retried next run). + noop=true +elif [ "$blocked" = true ]; then + # A human owns this diff; there is nothing for the automation to do. + noop=true +elif [ -n "$existing_pr_number" ]; then + body_yaml="$(gh pr view "$existing_pr_number" --json body -q '.body' 2>/dev/null | tr -d '\r' || true)" + + tracking_value() { + local name="$1" + # Tolerate an optional leading markdown list/quote marker (-, *, +, >) before the field + # name. The identity guard that gates a PR write accepts the field in this same anchored, + # tolerant form, so anything it admits is recoverable here. + sed -n "s/^[[:space:]]*[-*+>]*[[:space:]]*${name}:[[:space:]]*//p" | + head -1 | tr -d '"'\''\r' | sed 's/[[:space:]]*#.*$//; s/[[:space:]]*$//' + } + + tracking_block() { + # Emit only the machine-managed state block -- the fenced yaml block the agent writes as the + # VERY LAST thing in the body, delimited by the visible `# api-diff:state:begin`/`:end` + # comment lines (matched as whole comment lines: leading indent and a trailing CR are tolerated, + # but the "# " prefix and ":state:begin"/":state:end" suffix must be exact). Take the LAST + # begin..end range so human prose anywhere above it -- a quoted or bulleted "field:" line, an + # older state block a maintainer pastes in as a note, or a "+ field:" line inside a ```diff + # report -- cannot win tracking_value's first match and force a false caught-up no-op or silently + # suppress real reviewer feedback via a bogus watermark. A begin with no matching end (a human + # truncated the block) still yields its fields rather than an empty read. + awk -v b="# api-diff:state:begin" -v e="# api-diff:state:end" ' + { t=$0; sub(/\r$/,"",t); gsub(/^[[:space:]]+|[[:space:]]+$/,"",t) } + t == b { inb=1; buf=$0 ORS; next } + inb { buf=buf $0 ORS; if (t == e) { inb=0; last=buf } } + END { if (inb) last=buf; printf "%s", last }' + } + + recorded="$(printf '%s\n' "$body_yaml" | tracking_block | tracking_value "current-version")" + watermark="$(printf '%s\n' "$body_yaml" | tracking_block | tracking_value "generated-at")" + recorded_status="$(printf '%s\n' "$body_yaml" | tracking_block | tracking_value "status")" + + # Whether the resolved PR already carries THIS target's marker. An adopted (human- + # bootstrapped) PR or one whose marker line a human deleted has no marker yet; we + # must (re)write it, so such a PR never qualifies for a silent no-op. Uses the same + # whole-line matcher as the PR resolution above (single source of truth). + has_marker="$(jq -rn --arg b "$body_yaml" --arg m "$marker" \ + '($b | gsub("\r";"") | split("\n") | any(gsub("^[ \t]+|[ \t]+$";"") == "# " + $m))')" + # Body-free, author-agnostic wake gate: is there any review activity newer than the + # recorded watermark? This only decides whether a caught-up PR is worth waking the + # agent for -- it NEVER reads comment bodies, authors, or trust level. The agent + # collects and evaluates the actual review feedback itself under gh-aw integrity + # filtering (min-integrity: approved + integrity-reactions), the single source of + # truth for which feedback is trusted and actionable. Over-waking (e.g. for an + # un-endorsed external comment the agent will not act on) is harmless; under-waking + # is not. Any query failure is fail-closed to a stand-down (below): we must not + # advance the generated_at watermark off an incomplete read, or feedback that + # arrived would be silently skipped -- so we publish nothing and retry next run. + feedback_query_failed=false + if [ -n "$REPO" ] && [ -n "${GH_TOKEN:-}" ]; then + fb_times="$(mktemp)" + if ! gh api "repos/${REPO}/issues/${existing_pr_number}/comments" --paginate \ + -q '.[] | select(.user.type != "Bot") | .created_at' >>"$fb_times" 2>/dev/null; then feedback_query_failed=true; fi + if ! gh api "repos/${REPO}/pulls/${existing_pr_number}/comments" --paginate \ + -q '.[] | select(.user.type != "Bot") | .created_at' >>"$fb_times" 2>/dev/null; then feedback_query_failed=true; fi + if ! gh api "repos/${REPO}/pulls/${existing_pr_number}/reviews" --paginate \ + -q '.[] | select(.user.type != "Bot") | .submitted_at' >>"$fb_times" 2>/dev/null; then feedback_query_failed=true; fi + if [ "$feedback_query_failed" = false ]; then + new_count="$(awk -v since="$watermark" 'NF && (since=="" || $0 > since)' "$fb_times" | grep -c . || true)" + [ "${new_count:-0}" -gt 0 ] && has_new_feedback=true + fi + rm -f "$fb_times" + fi + if [ "$feedback_query_failed" = true ]; then + # Fail closed: a review-activity query failed, so the real count is unknown. + # Stand down without publishing so generated_at is not advanced and the same + # feedback window is retried next run rather than being skipped. + noop=true + echo "::warning::review-activity query failed for PR #${existing_pr_number}; standing down (generated_at not advanced, retried next run)." + elif [ "$recorded" = "$current_version" ]; then + if [ "$recorded_status" = "$status" ] && [ "$has_marker" = true ] && [ "$has_new_feedback" != true ]; then + noop=true + echo "::notice::no-op: ${current_version_milestone} unchanged (${current_version}), status '${status}', no new review activity since ${watermark:-}." + else + metadata_only=true + echo "::notice::metadata-only: build unchanged; status '${recorded_status:-?}'->'${status}', new_feedback=${has_new_feedback}, marker_present=${has_marker}." + fi + fi +fi + +# ---- 2. Position the working tree on the branch the agent will publish ------ +# Incremental (an existing PR): check out the maintained PR branch at its current tip so +# generation writes the refreshed reports onto it and the agent's commit APPENDS to the +# branch (push-to-pull-request-branch fast-forwards it -- no capture/restore, no force). +# Fresh (no PR): detach at the base commit so the agent's commit is a clean child of the +# base that create-pull-request diffs against. +# Reach the branch from the ref the checkout's `fetch: ["*"]` already populated; fall back to +# a tokened fetch (the agent checkout does not persist credentials, so a plain +# `git fetch origin ` neither authenticates nor populates refs/remotes/origin/). +if [ "$noop" = false ]; then + if [ -n "$existing_pr_number" ]; then + if git rev-parse --verify --quiet "refs/remotes/origin/${target_branch}" >/dev/null; then + git checkout -q -B "$target_branch" "refs/remotes/origin/${target_branch}" + echo "::notice::checked out maintained PR branch ${target_branch}" + elif [ -n "${GH_TOKEN:-}" ] && [ -n "$REPO" ] && + git fetch --no-tags "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$target_branch" >/dev/null 2>&1; then + git checkout -q -B "$target_branch" FETCH_HEAD + echo "::notice::checked out maintained PR branch ${target_branch} (tokened fetch)" + else + echo "::error::could not check out maintained PR branch ${target_branch} for the incremental update." + exit 1 + fi + else + git checkout -q --detach + echo "::notice::detached at the base commit for a fresh api-diff PR." + fi +fi +mkdir -p "$content_dir" +[ -f "$temp_attrs" ] || : >"$temp_attrs" + +# Guarantee every exclusion file ends with a newline before generation merges them +# and before the agent appends new entries. Without this, a repo file whose last +# line has no trailing newline (as `release-notes/ApiDiffAttributesToExclude.txt` +# ships) would concatenate the next appended `T:...` onto the previous entry and +# corrupt the list. +ensure_trailing_newline() { + local f="$1" + [ -f "$f" ] && [ -s "$f" ] || return 0 + [ -z "$(tail -c1 "$f")" ] || printf '\n' >>"$f" +} +ensure_trailing_newline "$perm_attrs" +ensure_trailing_newline "$perm_asms" +ensure_trailing_newline "$temp_attrs" + +temp_keep="$(mktemp)" +cp "$temp_attrs" "$temp_keep" + +# ---- 3. Generate (only on a full-refresh run) ------------------------------ +# Skipped for no-op (nothing changed) and for metadata-only (build unchanged, so +# the seeded reports are already correct -- we only refresh body/ready/exclusions). +if [ "$track" = "major-to-major" ] && [ "$cur_milestone" != "ga" ]; then + tool_output_dir="${CONTENT_ROOT}/${major}/preview/${cur_milestone}/api-diff" +else + tool_output_dir="$content_dir" +fi + +gen_ok=true +if [ "$noop" = false ] && [ "$metadata_only" = false ]; then + merged_attrs="$(mktemp)" + [ -f "$perm_attrs" ] && cat "$perm_attrs" >>"$merged_attrs" + if [ -s "$temp_attrs" ]; then + grep -vE '^[[:space:]]*(#|$)' "$temp_attrs" >>"$merged_attrs" 2>/dev/null || true + fi + # de-duplicate + sort -u "$merged_attrs" -o "$merged_attrs" + + # RunApiDiff.ps1 resolves a non-rooted exclude-file path relative to its own + # script dir (release-notes/), so a repo-relative path like + # release-notes/ApiDiffAssembliesToExclude.txt would double to + # release-notes/release-notes/... -- pass absolute paths. + abs_asms="$perm_asms" + case "$abs_asms" in /*) ;; *) abs_asms="$(pwd)/$abs_asms" ;; esac + + if command -v pwsh >/dev/null 2>&1 && [ -f "${CONTENT_ROOT}/RunApiDiff.ps1" ]; then + echo "::group::api-diff ${track}: ${previous_version} -> ${current_version}" + TIMEOUT=() + command -v timeout >/dev/null && TIMEOUT=(timeout "${API_DIFF_TIMEOUT:-1800}") + ASMS_ARG=() + [ -f "$abs_asms" ] && ASMS_ARG=(-AssembliesToExcludeFilePath "$abs_asms") + # Any SDK download failure inside RunApiDiff is a terminating error, so a + # non-zero exit means the run is incomplete -- capture it rather than + # swallowing it, so we never publish a partial diff. + rc=0 + "${TIMEOUT[@]}" pwsh "${CONTENT_ROOT}/RunApiDiff.ps1" \ + -PreviousVersion "$previous_version" -PreviousNuGetFeed "$previous_feed" \ + -CurrentVersion "$current_version" -CurrentNuGetFeed "$current_feed" \ + -AttributesToExcludeFilePath "$merged_attrs" \ + "${ASMS_ARG[@]}" \ + -CoreRepo "$(pwd)" -InstallApiDiff || rc=$? + echo "::endgroup::" + if [ "$rc" -ne 0 ]; then + gen_ok=false + echo "::warning::RunApiDiff exited ${rc}; treating this run as an incomplete build (no partial PR will be published)." + fi + else + gen_ok=false + echo "::warning::pwsh or ${CONTENT_ROOT}/RunApiDiff.ps1 unavailable; cannot generate." + fi + rm -f "$merged_attrs" +fi + +# ---- 3b. Verify at least one SDK report exists, then relocate major-to-major output - +# RunApiDiff.ps1 hard-fails if any SDK's ref packs can't be downloaded, so a successful +# run means every SDK was compared and a missing per-SDK report is a legitimate +# zero-change result (e.g. WindowsDesktop unchanged this milestone) -- publishable. We +# still require at least one report so an entirely empty result never opens/refreshes a +# PR, and a partially-published build is already caught upstream (RunApiDiff errors, not +# a silently-absent report). +if [ "$noop" = false ] && [ "$metadata_only" = false ]; then + if [ "$gen_ok" = true ]; then + present="" + absent="" + for sdk in NETCore AspNetCore WindowsDesktop; do + if [ -n "$(find "${tool_output_dir}/Microsoft.${sdk}.App" -type f -name '*.md' ! -name 'README.md' -print -quit 2>/dev/null)" ]; then + present="${present} ${sdk}" + else + absent="${absent} ${sdk}" + fi + done + if [ -z "$present" ]; then + gen_ok=false + echo "::warning::incomplete API diff: no SDK produced a report; not publishing an empty diff." + elif [ -n "$absent" ]; then + echo "::notice::no API changes for${absent} this milestone; publishing reports for${present}." + fi + fi + if [ "$gen_ok" = true ] && [ "$track" = "major-to-major" ] && [ "$tool_output_dir" != "$content_dir" ]; then + echo "::notice::relocating major-to-major output ${tool_output_dir} -> ${content_dir}" + rm -rf "$content_dir" + mkdir -p "$(dirname "$content_dir")" + mv "$tool_output_dir" "$content_dir" + rmdir "${CONTENT_ROOT}/${major}/preview/${cur_milestone}" 2>/dev/null || true + # The rolling cumulative diff is milestone-agnostic. RunApiDiff names its reports after + # the current head build (e.g. "11.0-preview7_Foo.md" plus the "11.0-preview7.md" table + # of contents that links to them), but this evergreen "(X-1) GA -> X" PR must use the + # stable "${major}.0" prefix RunApiDiff itself emits once the head reaches GA (matching + # the shipped 9.0.0_*.md convention). Rewriting the milestone prefix off the report file + # names and their in-file links keeps advancing the head (preview7 -> preview8 -> ... -> + # GA) a content-only update instead of a churn that renames every report each milestone. + stale_prefix="${major}-${cur_milestone}" + stable_prefix="${major}.0" + if [ "$stale_prefix" != "$stable_prefix" ]; then + find "$content_dir" -type f -name "${stale_prefix}*" | while IFS= read -r f; do + b="$(basename "$f")" + mv "$f" "$(dirname "$f")/${stable_prefix}${b#"$stale_prefix"}" + done + esc="$(printf '%s' "$stale_prefix" | sed 's/\./\\./g')" + find "$content_dir" -type f -name '*.md' -exec sed -i.bak "s/${esc}/${stable_prefix}/g" {} + + find "$content_dir" -type f -name '*.md.bak' -delete + fi + fi +fi + +# Restore the durable per-report temporary list, but keep the file only when it +# actually carries temporary exclusions -- an empty placeholder is committed noise. +cp "$temp_keep" "$temp_attrs" +if ! grep -qE '^[[:space:]]*[^[:space:]#]' "$temp_attrs" 2>/dev/null; then + rm -f "$temp_attrs" +fi + +# ---- 4. Agent target.json -------------------------------------------------- +report_count="$(find "$content_dir" -type f -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')" +# produce gate: +# - blocked / no-op -> never publish +# - metadata-only -> publish iff seeded reports exist (body/ready/feedback refresh) +# - full refresh -> publish iff generation completed AND reports exist +# Gating on gen_ok is critical: a failed or incomplete regeneration can leave stale +# or partial seeded reports in content_dir, and we must not publish those. +if [ "$noop" = true ] || [ "$blocked" = true ]; then + produce=false + # Defensive: metadata_only is mutually exclusive with noop/blocked upstream, but + # reset it here too so the gate alone guarantees produce=false never pairs with + # metadata_only=true (an unreconcilable manifest), like the report_count=0 branch. + metadata_only=false +elif [ "$report_count" -eq 0 ]; then + produce=false + if [ "$metadata_only" = true ]; then + # A metadata-only refresh requires the existing reports to have been + # seeded from the branch. Zero reports here means seeding did not run + # (e.g. the branch fetch failed), so keep the flags consistent instead + # of emitting produce=false with metadata_only=true, which the agent + # cannot reconcile. Leaving generated_at un-advanced re-wakes on the + # same review activity next run rather than dropping it. + metadata_only=false + echo "::warning::metadata-only refresh for ${current_version_milestone} found no seeded reports; standing down so new review activity is retried next run." + else + echo "::notice::no reports for ${current_version_milestone} (no build / no API changes); no PR will be opened." + fi +elif [ "$metadata_only" = true ]; then + produce=true +elif [ "$gen_ok" = true ]; then + produce=true +else + produce=false +fi +temp_excluded='[]' +if [ -s "$temp_attrs" ]; then + temp_excluded="$(jq -Rsc 'split("\n") | map(select(test("^\\s*(#|$)") | not))' "$temp_attrs" 2>/dev/null || echo '[]')" + [ -n "$temp_excluded" ] || temp_excluded='[]' +fi + +if [ "$status" = "code-complete" ]; then + status_note="**Ready for Review** — ${cur_human} is code complete (development has moved on to the next major)." +else + status_note="**Draft** — auto-maintained while ${cur_human} is the in-development frontier." +fi +if [ "$track" = "major-to-major" ]; then + tldr="Cumulative public API diff **.NET ${previous_version_milestone%%.*} → .NET ${major%.*}**, currently reflecting ${cur_human}. Refreshes as .NET ${major%.*} advances toward release." +else + tldr="Incremental public API diff **${prev_human} → ${cur_human}**." +fi + +jq -n \ + --argjson target "$TARGET" \ + --arg content_dir "$content_dir" \ + --arg perm_attrs "$perm_attrs" \ + --arg perm_asms "$perm_asms" \ + --arg temp_attrs "$temp_attrs" \ + --arg marker "$marker" \ + --arg pr_title "$pr_title" \ + --arg tldr "$tldr" \ + --arg status_note "$status_note" \ + --arg target_branch "$target_branch" \ + --arg existing_pr_number "$existing_pr_number" \ + --argjson produce "$produce" \ + --argjson noop "$noop" \ + --argjson metadata_only "$metadata_only" \ + --argjson blocked "$blocked" \ + --argjson report_count "$report_count" \ + --argjson temp_excluded_attributes "$temp_excluded" \ + --argjson has_new_feedback "$has_new_feedback" \ + --arg watermark "$watermark" \ + --arg generated_at "$generated_at" \ + '$target + { + content_dir: $content_dir, + permanent_attributes_file: $perm_attrs, + permanent_assemblies_file: $perm_asms, + temporary_attributes_file: $temp_attrs, + marker: $marker, + pr_title: $pr_title, + tldr: $tldr, + status_note: $status_note, + target_branch: $target_branch, + existing_pr_number: $existing_pr_number, + produce: $produce, + noop: $noop, + metadata_only: $metadata_only, + blocked: $blocked, + report_count: $report_count, + temp_excluded_attributes: $temp_excluded_attributes, + has_new_feedback: $has_new_feedback, + watermark: $watermark, + generated_at: $generated_at + }' >"$AGENT_DIR/target.json" + +rm -f "$temp_keep" +echo "=== target.json ===" +jq '.' "$AGENT_DIR/target.json" +echo "noop=${noop} metadata_only=${metadata_only} blocked=${blocked} produce=${produce} reports=${report_count} new_feedback=${has_new_feedback} branch=${target_branch}" + +# ---- 5. Always emit a run overview to the job step summary (incl. no-op) ---- +# Every worker run -- publish, metadata-only refresh, blocked, or a silent +# no-op -- writes a one-glance summary so the outcome is visible without digging +# through logs. +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [ "$blocked" = true ]; then + decision="🚫 Blocked — a non-automation \`api-diff\` PR already owns this diff; standing down." + elif [ "$noop" = true ]; then + decision="😴 No-op — build unchanged, same status, no new review activity. Nothing to do." + elif [ "$metadata_only" = true ] && [ "$produce" = true ]; then + decision="📝 Metadata-only refresh — body/ready/feedback update, no regeneration." + elif [ "$produce" = true ]; then + decision="✅ Publish — ${report_count} report file(s) staged." + else + decision="⏭️ Nothing to publish — no build available or generation was incomplete." + fi + { + echo "## API Diff -- ${pr_title}" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Track | \`${track}\` |" + echo "| Diff | \`${previous_version_milestone}\` (\`${previous_version}\`) → \`${current_version_milestone}\` (\`${current_version}\`) |" + echo "| Status | \`${status}\` |" + echo "| Decision | ${decision} |" + echo "| Reports | ${report_count} |" + echo "| New review activity | ${has_new_feedback} |" + echo "| Target branch | \`${target_branch}\` |" + echo "| Existing PR | ${existing_pr_number:-—} |" + echo "| Generated at | ${generated_at} |" + } >>"$GITHUB_STEP_SUMMARY" +fi diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md new file mode 100644 index 00000000000..0de83e89d40 --- /dev/null +++ b/.github/skills/agentic-workflows/SKILL.md @@ -0,0 +1,83 @@ +--- +name: agentic-workflows +description: Route gh-aw workflow design/create/debug/upgrade requests to the right prompts. +--- + +# Agentic Workflows Router + +Use this skill when a user asks to design, create, update, debug, or upgrade GitHub Agentic Workflows in this repository. + +This skill is a dispatcher: identify the task type, load the matching workflow prompt/skill file, and follow it directly. Keep responses concise and ask a clarifying question if the correct prompt is unclear. + +Read only the files you need: +Load these files from `github/gh-aw` (they are not available locally). +- `.github/aw/agentic-chat.md` +- `.github/aw/agentic-workflows-mcp.md` +- `.github/aw/asciicharts.md` +- `.github/aw/campaign.md` +- `.github/aw/charts-trending.md` +- `.github/aw/charts.md` +- `.github/aw/cli-commands.md` +- `.github/aw/context.md` +- `.github/aw/create-agentic-workflow.md` +- `.github/aw/create-shared-agentic-workflow.md` +- `.github/aw/debug-agentic-workflow.md` +- `.github/aw/dependabot.md` +- `.github/aw/deployment-status.md` +- `.github/aw/experiments.md` +- `.github/aw/github-agentic-workflows.md` +- `.github/aw/github-mcp-server.md` +- `.github/aw/llms.md` +- `.github/aw/loop.md` +- `.github/aw/lsp.md` +- `.github/aw/mcp-clis.md` +- `.github/aw/memory-stateful-patterns.md` +- `.github/aw/memory.md` +- `.github/aw/messages.md` +- `.github/aw/network.md` +- `.github/aw/optimize-agentic-workflow.md` +- `.github/aw/patterns.md` +- `.github/aw/pr-reviewer.md` +- `.github/aw/report.md` +- `.github/aw/reuse.md` +- `.github/aw/safe-outputs-automation.md` +- `.github/aw/safe-outputs-content.md` +- `.github/aw/safe-outputs-management.md` +- `.github/aw/safe-outputs-runtime.md` +- `.github/aw/safe-outputs.md` +- `.github/aw/serena-tool.md` +- `.github/aw/shared-safe-jobs.md` +- `.github/aw/skills.md` +- `.github/aw/subagents.md` +- `.github/aw/syntax-agentic.md` +- `.github/aw/syntax-core.md` +- `.github/aw/syntax-tools-imports.md` +- `.github/aw/syntax.md` +- `.github/aw/test-coverage.md` +- `.github/aw/test-expression.md` +- `.github/aw/token-optimization.md` +- `.github/aw/triggers.md` +- `.github/aw/update-agentic-workflow.md` +- `.github/aw/upgrade-agentic-workflows.md` +- `.github/aw/visual-regression.md` +- `.github/aw/workflow-constraints.md` +- `.github/aw/workflow-editing.md` +- `.github/aw/workflow-patterns.md` + +- `.github/skills/agentic-workflow-designer/SKILL.md` +After loading the matching workflow prompt or skill, follow it directly: +- Design workflows from scratch via interview: `skills/agentic-workflow-designer/SKILL.md` +- Create new workflows: `.github/aw/create-agentic-workflow.md` +- Update existing workflows: `.github/aw/update-agentic-workflow.md` +- Debug, audit, or investigate workflows: `.github/aw/debug-agentic-workflow.md` +- Upgrade workflows and fix deprecations: `.github/aw/upgrade-agentic-workflows.md` +- Create shared components or MCP wrappers: `.github/aw/create-shared-agentic-workflow.md` +- Create report-generating workflows: `.github/aw/report.md` +- Fix Dependabot manifest PRs: `.github/aw/dependabot.md` +- Analyze coverage workflows: `.github/aw/test-coverage.md` +- Render compact markdown charts: `.github/aw/asciicharts.md` +- Map CLI commands to MCP usage: `.github/aw/cli-commands.md` +- Choose workflow architecture and patterns: `.github/aw/patterns.md` +- Optimize token usage and cost: `.github/aw/token-optimization.md` + +When the task involves OTEL, OTLP, traces, observability backends, or telemetry-driven analysis, also read and follow `skills/otel-queries/SKILL.md` after loading the matching workflow prompt or skill. diff --git a/.github/skills/api-diff-validation/SKILL.md b/.github/skills/api-diff-validation/SKILL.md new file mode 100644 index 00000000000..6cc13f6e6c5 --- /dev/null +++ b/.github/skills/api-diff-validation/SKILL.md @@ -0,0 +1,96 @@ +--- +name: api-diff-validation +description: > + Validate that a generated API diff matches the APIs actually shipped in a .NET + build, and that every new API traces to a real change. Uses dotnet-inspect to + query the build's ref packs and cross-references new APIs against candidate PRs + to catch missed reverts, renames, and kept-internal APIs before they reach + release notes. Distinct from the pure api-diff skill, which only generates the + diff reports. +--- + +# API Diff Validation + +Confirm that the **public APIs claimed by an API diff actually shipped** in the +target build, and that each new API traces to a real PR. This is the verification +layer on top of the pure `api-diff` skill (which only produces the diff reports). + +Use it to catch, before release notes are written: + +- APIs present in the diff but **missing from the shipped build** (a missed revert, + a rename, or an API kept internal), +- new APIs with **no implementing PR** (suspicious / mis-attributed). + +## Inputs + +- A generated API diff for the milestone (the `api-diff/` reports), or the target + release identity. +- **`build-metadata.json`** — `nuget.source` (the dnceng `dotnet` feed) and + the ref-pack package versions for the exact build. +- The milestone's `changes.json` / candidate PRs (for cross-referencing). + +`dotnet-inspect` is used here as a **tool**, not a separate skill: it reflects over +the real ref packs from the build's feed, so it answers "does this API exist in +*this* build?" authoritatively. + +## 1. Point at the right build (never the locally-installed SDK) + +Read `build-metadata.json` for the target. Use its `nuget.source` as `$FEED` and +the ref-pack versions (e.g. `Microsoft.NETCore.App.Ref@`) as `$VER`. For +behavior that requires the SDK (rare here), install it side-by-side and scoped — +see the `validate-code-samples` skill. Do **not** trust a machine-wide SDK for +preview work. + +## 2. Verify APIs exist with dotnet-inspect + +```bash +# Does a new type/member exist in the build's ref pack? +dnx dotnet-inspect -y -- find "*AnyNewLine*" \ + --package "Microsoft.NETCore.App.Ref@${VER}" --source "$FEED" + +# Enumerate a type's members to confirm a signature. +dnx dotnet-inspect -y -- member RegexOptions \ + --package "Microsoft.NETCore.App.Ref@${VER}" --source "$FEED" -k field + +# Diff the public API between two builds (sanity-check the generated diff). +dnx dotnet-inspect -y -- diff \ + --package "Microsoft.NETCore.App.Ref@${PREV}..${VER}" --source "$FEED" +``` + +For each API the diff claims is new, confirm it is present. If an API is **missing +from the build**, treat it as a serious signal: it may have been renamed, kept +internal, or reverted after the diff was captured — flag it for exclusion. + +## 3. Cross-reference new APIs against candidate PRs + +For each new API/namespace in the diff, verify at least one candidate PR (from +`changes.json`) covers it. If an API has **no matching PR**, search for the +implementing PR explicitly before trusting it: + +```bash +gh search prs --repo dotnet/ --merged "" \ + --json number,title,mergedAt,url +``` + +If no implementing PR can be found and `dotnet-inspect` cannot confirm the API, +do not document it. + +## 4. Detect missed reverts + +An API may appear in the diff yet have been **backed out** later. Confirm presence +in the build with `dotnet-inspect` (step 2) and check for a follow-up revert PR: + +```bash +gh search prs --repo dotnet/ --state merged \ + "\"This reverts\" " --json number,title,url +``` + +If the API is absent from the build or a merged revert exists, mark the feature as +reverted (suppress it / score it down in `features.json`). + +## 5. Report + +Emit a concise verdict per checked API: **present** (with PR), **missing** +(rename/internal/revert — exclude), or **unverified** (describe without naming the +type). Scope the report to the documented/announced surface; do not surface +undocumented deltas. diff --git a/.github/skills/api-diff/SKILL.md b/.github/skills/api-diff/SKILL.md index 637a026558d..fea67e65292 100644 --- a/.github/skills/api-diff/SKILL.md +++ b/.github/skills/api-diff/SKILL.md @@ -1,67 +1,22 @@ --- name: api-diff description: > - Download, inspect, and diff .NET APIs for a given build or release. Use - `dotnet-inspect` to verify that APIs actually exist in the shipped packages, - detect missed reverts, and generate before/after API diffs. Use - `RunApiDiff.ps1` when a full markdown diff report is needed. + Generate before/after .NET public API diff reports for a release milestone with + `RunApiDiff.ps1`. Pure report generation only; to verify the diff against the + shipped build (dotnet-inspect, revert detection) use the api-diff-validation skill. --- -# API Downloading, Verification, and Diffing +# API Diffing -Use this skill when you need evidence about the **actual public API surface** of a .NET build. +Use this skill to produce the markdown-ready, repo-shaped **API diff reports** for a +release milestone (previous milestone -> current). This skill is pure generation: it +runs the diff tool and writes the reports. Verifying that the diffed APIs actually +shipped, tracing them to PRs, and catching reverts is the separate +[api-diff-validation](../api-diff-validation/SKILL.md) skill. -This is primarily for: +## Generate the reports with `RunApiDiff.ps1` -- confirming whether an API really shipped in the build binaries/ref packs -- catching reverts or incomplete rollouts before they end up in release notes -- generating before/after API diffs for a release milestone - -## Preferred workflow - -### 1. Query the right build first - -Do **not** trust the locally installed SDK for preview work. Query the target build's packages directly. - -Use the process in [api-verification.md](../release-notes/references/api-verification.md): - -1. Generate or read `build-metadata.json` -2. Get `nuget.source` and the package versions for the target release -3. Run `dotnet-inspect` against those exact packages - -### 2. Verify APIs with `dotnet-inspect` - -Typical tasks: - -- **Find a type or member** to confirm the API exists -- **Compare versions** to see what changed between previews or between RC and GA -- **Validate naming** before writing prose or code samples - -Examples: - -```bash -# Find a type in the runtime ref pack -dnx dotnet-inspect -y -- find "*AnyNewLine*" \ - --package "Microsoft.NETCore.App.Ref@${VER}" \ - --source "$FEED" - -# Verify members on a type -dnx dotnet-inspect -y -- member RegexOptions \ - --package "Microsoft.NETCore.App.Ref@${VER}" \ - --source "$FEED" \ - -k field - -# Diff public APIs between two versions -dnx dotnet-inspect -y -- diff \ - --package "Microsoft.NETCore.App.Ref@11.0.0-preview.2..11.0.0-preview.3" \ - --source "$FEED" -``` - -If the API is missing from the target build, treat that as a serious signal: it may have been renamed, kept internal, or reverted. - -### 3. Use `RunApiDiff.ps1` for full diff reports - -When the user wants the markdown-ready, repo-shaped API diff output, use `release-notes/RunApiDiff.ps1`. See [release-notes/RunApiDiff.md](../../../release-notes/RunApiDiff.md) for the full parameter reference. +Use `release-notes/RunApiDiff.ps1`. See [release-notes/RunApiDiff.md](../../../release-notes/RunApiDiff.md) for the full parameter reference. ## Mapping natural language to parameters @@ -76,6 +31,7 @@ When the user wants the markdown-ready, repo-shaped API diff output, use `releas - **GA** or no qualifier -> omit the `PrereleaseLabel` parameter for `RunApiDiff.ps1`; if a downstream api-diff release label is needed, use `ga` - **Preview N** / **previewN** -> `-PrereleaseLabel preview.N` (for example, `preview.4`, not `preview4`) - **RC N** / **rcN** -> `-PrereleaseLabel rc.N` +- **Alpha N** / **Beta N** -> `-PrereleaseLabel