From f853efe03472429ea1ac34f55e9cc6ad2a40f997 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 00:15:36 +0200 Subject: [PATCH 01/15] docs: add the Native AOT PR 3 implementation plan Co-Authored-By: Claude Opus 5 (1M context) --- ...-09-14-native-aot-pr3-packaging-release.md | 1946 +++++++++++++++++ 1 file changed, 1946 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md diff --git a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md new file mode 100644 index 0000000..b74c1c6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md @@ -0,0 +1,1946 @@ +# Native AOT PR 3: Packaging and Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `ProjGraph.Cli` and `ProjGraph.Mcp` as RID-specific Native AOT tool packages with a framework-dependent `any` fallback. Every native package is smoke-tested on its own platform before a release pushes anything. + +**Architecture:** Both tool projects set `PublishAot=true` and `ToolPackageRuntimeIdentifiers`. A plain `dotnet pack` then produces only a small pointer package. Each native package comes from `dotnet pack -r ` on a matching OS, and the fallback from `dotnet pack -r any -p:PublishAot=false`. + +One bash script, `.github/scripts/native-tool-smoke.sh`, runs build → native pack → `dotnet tool install` from the local packages → smoke suite for one RID. A composite action runs that script on the runner, or inside an Alpine .NET SDK container for `linux-musl-*`. The existing `aot-smoke` PR job uses the action for linux-x64. + +A new reusable workflow, `pack.yml`, runs the action for all six native RIDs and packs the libraries, the fallbacks, and the pointer packages. It is called by `publish.yml` and also runs on PRs that touch packaging. `publish.yml` pushes every package except the pointers, waits until NuGet.org lists all 14 tool sub-packages, then pushes the pointers. The smoke suite also gains the PR 2 review follow-ups: a guard that the "native" build really is Native AOT, and MCP server stderr in failure output. + +**Tech Stack:** .NET 10 (SDK per `global.json`; verified on 10.0.401), C#, xUnit 2.9 + FluentAssertions 8, ModelContextProtocol 2.2.0, GitHub Actions (hosted `windows-latest`, `ubuntu-latest`, `ubuntu-24.04-arm`, `macos-latest`), `mcr.microsoft.com/dotnet/sdk:10.0-alpine`, bash, actionlint/shellcheck (via Docker). + +**Spec:** `docs/superpowers/specs/2026-09-14-native-aot-design.md` (section "PR 3: packaging and release", plus "Testing", "Risks", and "Exit criteria"). Read it before starting. PR 1 (#191, `86082ea`) and PR 2 (#192, `fe0f3fd`) are merged. PR 2's plan is `docs/superpowers/plans/2026-09-14-native-aot-pr2-native-build-smoke.md`. + +## Global Constraints + +- `TreatWarningsAsErrors=true`, `AnalysisMode=All`, and `EnforceCodeStyleInBuild=true` apply to every project, tests included. Sonar, Roslynator, VS Threading, and CA analyzers run during build, so any warning fails it. Private members that have a `` also need their ``/``/`` elements (RCS1140/RCS1141). +- XML documentation is required on all public APIs. Test projects disable CS1591, but the Roslynator rules above still apply to any doc comment you write. +- CI runs `dotnet format ProjGraph.slnx --no-restore --verify-no-changes`, which must pass. +- Package versions are central (`Directory.Packages.props`). This PR adds no package. +- No change to any command, option, MCP tool, prompt, resource, or output format (spec Non-goals). +- Smoke-test oracle: native output is compared with the JIT build of the **same commit**, never with committed samples (spec decision 7). +- **Native RIDs (spec decision 3, widened, see deviation 1):** `win-x64`, `linux-x64`, `linux-arm64`, `linux-musl-x64`, `linux-musl-arm64`, `osx-arm64`, plus `any`. The list must be identical in both csproj files, in `pack.yml`'s matrix, and in the scripts' package sets. +- **Release package set: 22 packages.** 6 libraries, 2 pointer packages (`ProjGraph.Cli`, `ProjGraph.Mcp`), 2 `any` packages, and 12 native packages (2 × 6 RIDs). +- **Pointer packages are pushed last**, only after NuGet.org's flat container lists every one of the 14 tool sub-packages (spec 3.2 step 4.3). +- **Run native packs one at a time.** Each ILC run takes several GB of RAM, and back-to-back AOT builds have been OOM-killed on the 16 GB dev machine. `/tmp` on that machine is RAM-backed tmpfs, so delete scratch clones when you're done with them. +- **Before a local native pack, delete `src/ProjGraph.{Cli,Mcp}/bin/Release/net10.0/`.** A pack reuses that `publish/` folder and ships whatever it finds there, including a stale `.dbg` from an earlier publish. +- **Local `dotnet tool install` always uses a fresh `NUGET_PACKAGES` folder.** Otherwise a previously installed package with the same version is served from the global cache. +- Don't edit `ProjGraph.slnx` with `dotnet sln add` (it rewrites every `` line). This PR doesn't need to touch it. +- **Create files with the Write tool or an editor, never a shell heredoc.** On the dev machine, a token-saving command hook (DTK/RTK) rewrites `dotnet restore|build|test` into `dtk dotnet …`, even inside heredoc text passed to `bash`. During planning this silently corrupted the scripts and workflows, and the Alpine run then failed with `dtk: command not found`. `grep -rnE '\b(dtk|rtk) ' .github CONTRIBUTING.md CLAUDE.md ARCHITECTURE.md` must print nothing before every commit that touches those files. The same hook also summarizes `dotnet` output; to see raw output, call the SDK by its full path, `"$(readlink -f "$(command -v dotnet)")"`. +- Pushing, opening the PR, tagging, and repository-settings changes are outward-facing: confirm with the user before each. + +## Deviations from the spec (intentional; mention them in the PR description) + +1. **Two extra native RIDs, `linux-musl-x64` and `linux-musl-arm64` (user decision, 2026-09-14).** + - Why: without them, `dotnet tool install`/`dnx` on Alpine resolves the glibc `linux-x64` package, not `any`, and the tool fails with `projgraph: not found`. This was reproduced in `mcr.microsoft.com/dotnet/sdk:10.0-alpine`. The RID graph treats `linux-musl-x64` as compatible with `linux-x64`, and `linux-musl-arm64` with `linux-arm64` the same way. + - With `linux-musl-x64` listed, Alpine selects the musl package and the glibc host still selects `linux-x64` (both verified). + - The musl packages are built and smoke-tested inside the Alpine SDK container, because musl binaries must link on a musl system. + - The release grows from 18 packages to 22, and from 10 polled sub-packages to 14. +2. **`pack.yml` is a reusable workflow, and it also runs on PRs that change packaging.** The spec puts `pack-native`/`pack-portable` directly in `publish.yml`. As a separate workflow, a PR that touches the tool projects, the smoke suite, the packaging scripts, `Directory.*.props`, or `global.json` builds and smoke-tests every platform before merge. Otherwise the first Windows, macOS, Arm64, or musl native build would happen on a release tag. `publish.yml` calls it with `uses:`, so the release job graph is the spec's `prepare → pack-native ×6 + pack-portable → publish`. +3. **The release smoke installs the tools with `dotnet tool install`** from the pointer package plus the native package, instead of extracting executables from the `.nupkg`. + - This exercises the pointer's RID resolution and the exec-bit handling. Extracting would lose the exec bit, so the PR 2 note "chmod +x on extracted executables" doesn't apply. + - The install uses a NuGet config that lists only the local packages and a fresh package folder. A missing native package fails the install (verified) instead of falling back to NuGet.org. +4. **`aot-smoke` uses the same pack → install → smoke path** through the shared composite action, instead of `dotnet publish`. The job id stays `aot-smoke`, so the pending "make it required" step still applies. +5. **Native debug symbols aren't packaged.** + - The setting is `CopyOutputSymbolsToPublishDirectory=false` when `PublishAot=true`. + - Why: the linux-x64 CLI package otherwise carries `ProjGraph.Cli.dbg`, 95 MB raw and 18.4 MB compressed, which makes the package 32.3 MB instead of 14.7 MB. + - The `any` packages keep their `.pdb` files. +6. **`IncludeSymbols=false` on both tool projects.** A pointer package has no assemblies, and packing its symbols package fails with `NU5017: Cannot create a package that has no dependencies nor content`. That breaks `dotnet pack ProjGraph.slnx`. The two tool `.snupkg` files stop shipping; the six library `.snupkg` files are unchanged. +7. **Pointer packages travel separately.** `pack-portable` uploads them as a `pointers` artifact, and `publish` keeps them in `artifacts/pointers`. Push order is therefore a directory choice, not a file-name filter. +8. **`pack-portable` smoke-tests the `any` packages too.** It runs their `.dll`s against the JIT build, with the native-build guard filtered out. The guard would otherwise fail, because a fallback package is a JIT build. +9. **The MCP reference on Windows is the JIT apphost `ProjGraph.Mcp.exe`.** The SDK wraps stdio commands in `cmd.exe /c`, which is fragile with a quoted `C:\Program Files\dotnet\dotnet.exe` host path. +10. **`Tests.Integration.Mcp`'s `McpServerProcess` changes.** + - ProjGraph.Mcp is no longer self-contained, so its apphost moves from `bin//net10.0//` to `bin//net10.0/`, and the helper now launches it from there. + - The spec didn't anticipate this; without the change, the four `McpTransportTests` fail. +11. **PR 2 final-review follow-ups included:** + - the `NativeBuildGuardTests` class; + - MCP server stderr captured into the test output; + - the IL3050 suppression narrowed to a `CreateApp` helper; + - split pack steps; + - TRX upload on failure. +12. **Plain JIT builds now carry AOT feature switches.** With `PublishAot` in the csproj, `dotnet build` writes `IsDynamicCodeSupported=false`, `JsonSerializer.IsReflectionEnabledByDefault=false`, and similar switches into `ProjGraph.Cli.runtimeconfig.json`/`ProjGraph.Mcp.runtimeconfig.json`. The smoke reference and `dotnet run` therefore run with them. The `any` packages (`-p:PublishAot=false`) don't, which is why deviation 8 tests them separately. + +## Verified facts this plan relies on + +Checked on 2026-09-14 in throwaway copies of `develop` at `fe0f3fd` (linux-x64, SDK 10.0.401), plus `mcr.microsoft.com/dotnet/sdk:10.0-alpine` (10.0.401) for musl. + +**Build and tests** + +- Build with both csproj changes from Task 3: 0 warnings, 0 errors. +- Test results before and after the `McpServerProcess` fix: + - Before: 1,174 passed and 4 failed. All four failures are `McpTransportTests`, with `Expected serverExe not to be because the MCP server apphost must be present under …/src/ProjGraph.Mcp/bin/Release/net10.0.` + - After: `Tests.Integration.Mcp` passes 150/150, the whole solution passes 1,178, and the smoke suite reports 30 skipped. + +**Packing** + +- `dotnet pack ProjGraph.slnx` with the tools' `IncludeSymbols` still on fails NU5017 for both tool projects. +- With `IncludeSymbols=false`, it produces 6 library `.nupkg` + 6 `.snupkg`, and 2 pointer packages of about 32 KB. +- Pointer package contents: + - `tools/any/any/DotnetToolSettings.xml` with one `` per listed RID. + - Package type `DotnetTool`, plus `McpServer` for the MCP pointer. + - The MCP pointer also contains `.mcp/server.json`, `README.md`, and `icon.png` (spec 3.1). +- `dotnet pack src/ProjGraph.Cli -r any -p:PublishAot=false`: + - Produces `ProjGraph.Cli.any..nupkg` (8.2 MB, type `DotnetToolRidPackage`, files under `tools/net10.0/any/`, runtimeconfig without AOT switches). + - Builds into `bin/Release/net10.0/any/`. +- `dotnet pack src/ProjGraph.Cli -r linux-x64`: + - Takes 47 s (the MCP pack took 60 s). + - Produces `ProjGraph.Cli.linux-x64..nupkg` with the binary at `tools/any/linux-x64/ProjGraph.Cli`. + - Doesn't touch the JIT build: all 382 `ProjGraph.*` files under `src/*/bin/Release` and `tests/*/bin/Release` were byte-identical before and after. The pack only added `bin/Release/net10.0/linux-x64/`. + - ILC prints third-party `IL2104`/`IL3053`/`IL3000` warnings and the known informational `OpenCliParser … NJsonSchema` line. These don't fail the pack. +- Sizes with native symbols excluded: `ProjGraph.Cli.linux-x64` 14.7 MB, `ProjGraph.Mcp.linux-x64` 17.1 MB. The MCP binary is 42,946,344 bytes, identical to PR 2. + +**Installing and resolution** + +- `dotnet tool install ProjGraph.Cli --version --configfile --tool-path `: + - Selects `projgraph.cli.linux-x64`. + - `/projgraph` is a symlink to `.store/projgraph.cli//projgraph.cli.linux-x64//tools/any/linux-x64/ProjGraph.Cli`, with the exec bit set. + - `ProjGraph.Mcp` installs the command `ProjGraph.Mcp` the same way. +- With the native package missing, the install fails: `Version of package projgraph.cli.linux-x64 is not found in NuGet feeds …`, exit 1. There is no fallback to `any`. +- Alpine: + - Pointer without musl RIDs: it installs `projgraph.cli.linux-x64`, and running it prints `sh: /tmp/t/projgraph: not found`. + - Pointer listing `linux-musl-x64`: Alpine installs `projgraph.cli.linux-musl-x64`, and `projgraph stats ProjGraph.slnx` works. The glibc host still installs `linux-x64`. +- `dotnet pack src/ProjGraph.Cli -r linux-musl-x64` inside `sdk:10.0-alpine`, after `apk add --no-cache clang build-base zlib-dev`: 70 s, 15.4 MB. + +**Smoke suite** + +- The plan's `native-tool-smoke.sh`, run from a clean copy: + - `linux-x64` on the host with `GITHUB_ACTIONS=true`: TRX `total="30" passed="30"`. Packages are ~15.4 MB (CLI) and ~17.9 MB (MCP), pointers ~32 KB, and both commands link into the `linux-x64` packages. + - `linux-musl-x64` through the composite action's exact `docker run … sdk:10.0-alpine` command: `Passed: 30`, exit 0, and both commands link into the `linux-musl-x64` packages. The chown handed every file back to the host user. +- Installed native tools vs the JIT build (reference `.dll`s from `bin/Release/net10.0/`): 30/30 passed in 15 s with `GITHUB_ACTIONS=true`, including the two guard tests. +- The `any` package `.dll`s vs the JIT build (guard excluded): 28/28 passed in 29 s. +- Negative checks: + - With the "native" CLI set to the JIT apphost, `CliNative_ShouldBeNativeAotExecutable` fails: `…/ProjGraph.Cli has a managed entry point beside it, so it is a JIT apphost, not a Native AOT executable`. + - With the "native" MCP server set to a script that writes to stderr and exits 3, the 10 MCP parity tests fail. The exception message (SDK 2.2.0) already carries `Server's stderr tail`, and each failing test's `Standard Output Messages` shows `Native server stderr:` with the script's line. + - Totals for that run: 19 passed, 11 failed. + +**Other checks** + +- Without the suppression on `CreateApp`, `Program.cs(68,16): error IL3050` points at `new CommandApp(registrar)`. With it, the build is clean, and so is `dotnet format --verify-no-changes`. +- `https://api.nuget.org/v3-flatcontainer/projgraph.cli/index.json` lists `1.1.0`, and an unknown ID returns 404. +- `docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false ` works. So does `--entrypoint shellcheck`. Without `--user`, files with mode 600 are unreadable. +- The current `publish.yml` already has shellcheck `SC2086` infos; the new one must lint clean. +- MCP SDK 2.2.0 `StdioClientTransport` wraps any command other than `cmd.exe` in `cmd.exe /c` on Windows. Arguments without whitespace get `^`-escaping. +- `aot-smoke` isn't a required check yet. The `develop` ruleset has only deletion, non-fast-forward, and Copilot review rules. + +## File Structure + +| File | Change | Responsibility | +| --- | --- | --- | +| `tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs` | Create | Fails when a "native" variable points at a JIT apphost or `.dll` | +| `tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs` | Modify | Captures each server's stderr lines | +| `tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs` | Modify | Writes captured stderr to the xUnit test output | +| `src/ProjGraph.Cli/Program.cs` | Modify | IL3050 suppression narrowed to `CreateApp` | +| `src/ProjGraph.Cli/ProjGraph.Cli.csproj` | Modify | `PublishAot`, RID list, symbol settings | +| `src/ProjGraph.Mcp/ProjGraph.Mcp.csproj` | Modify | Same; drops `SelfContained`/`PublishSelfContained`/`PublishSingleFile` | +| `tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs` | Modify | Launches the framework-dependent apphost from `bin///` | +| `.github/scripts/native-tool-smoke.sh` | Create | Build → pack native → install → smoke, for one RID | +| `.github/actions/native-tool-smoke/action.yml` | Create | Runs the script on the runner, or in Alpine for `linux-musl-*` | +| `.github/scripts/verify-packages.sh` | Create | Requires a folder to hold exactly the named package sets | +| `.github/scripts/wait-for-nuget.sh` | Create | Polls NuGet.org's flat container until versions are listed | +| `.github/workflows/ci.yml` | Modify | `aot-smoke` uses the composite action | +| `.github/workflows/pack.yml` | Create | `pack-native` ×6 and `pack-portable`; reusable, also PR-triggered | +| `.github/workflows/publish.yml` | Modify | `prepare` → `pack` → `publish` with ordered pushes | +| `src/ProjGraph.Cli/README.md`, `src/ProjGraph.Mcp/README.md`, `docfx/guides/getting-started.md` | Modify | Installation note: SDK requirement, native platforms | +| `CLAUDE.md`, `CONTRIBUTING.md`, `ARCHITECTURE.md` | Modify | Packaging pattern, smoke suite, release flow | + +--- + +### Task 1: Smoke suite guard and MCP stderr capture + +Two PR 2 review follow-ups, verified before any packaging change with PR 2's publish commands: a guard that fails when a "native" build is really a JIT build, and MCP server stderr in the test output. + +**Files:** +- Create: `tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs` +- Modify: `tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs` +- Modify: `tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs` + +**Interfaces:** +- Consumes (existing, unchanged): `SmokeEnvironment.CliNative`/`McpNative` (`SmokeCommand`), `SmokeCommand(string FileName, IReadOnlyList LeadingArguments)`, `[AotSmokeFact]`. +- Produces: + - `public sealed class NativeBuildGuardTests` in namespace `ProjGraph.Tests.Smoke.Aot`. Task 5's `pack-portable` filters it out by the name `ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests`. + - `McpServerPair.DescribeStandardError()` returns a `string`. + - The suite now has 30 tests: 18 CLI, 10 MCP, and 2 guard tests. + +- [ ] **Step 1: Create the feature branch and commit this plan** + +```bash +git switch develop +git pull --ff-only +git switch -c feat/aot-packaging +git add docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md +git commit -m "docs: add the Native AOT PR 3 implementation plan" +``` + +(If the plan file is already committed, skip the `git add`/`commit`.) + +- [ ] **Step 2: Build the native binaries the PR 2 way (they're needed to run the new tests)** + +The csproj files don't set `PublishAot` yet, so this is PR 2's local loop from `CONTRIBUTING.md`. Run the two publishes one after the other: + +```bash +dotnet build ProjGraph.slnx -c Release +dotnet publish src/ProjGraph.Cli -c Release -r linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build -o artifacts/native/cli +dotnet publish src/ProjGraph.Mcp -c Release -r linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build -o artifacts/native/mcp +``` + +Expected: both publishes succeed, and `artifacts/native/cli/ProjGraph.Cli` and `artifacts/native/mcp/ProjGraph.Mcp` exist. + +- [ ] **Step 3: Write the guard tests** + +Create `tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs`: + +```csharp +using ProjGraph.Tests.Smoke.Aot.Helpers; + +namespace ProjGraph.Tests.Smoke.Aot; + +/// +/// Guards the suite's premise: the builds under test must be Native AOT executables. A JIT apphost +/// or a framework-dependent .dll matches the reference trivially, so a misconfigured job, or a +/// tool package that lost PublishAot, would otherwise pass every parity test. +/// +public sealed class NativeBuildGuardTests +{ + [AotSmokeFact] + public void CliNative_ShouldBeNativeAotExecutable() + { + AssertNativeAotExecutable(SmokeEnvironment.CliNative); + } + + [AotSmokeFact] + public void McpNative_ShouldBeNativeAotExecutable() + { + AssertNativeAotExecutable(SmokeEnvironment.McpNative); + } + + private static void AssertNativeAotExecutable(SmokeCommand command) + { + command.LeadingArguments.Should().BeEmpty("a Native AOT build starts directly, not through the dotnet host"); + + // dotnet tool install links the command to the executable in its package store on Linux and macOS. + var executable = new FileInfo(command.FileName); + var target = executable.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? executable.FullName; + + // A JIT apphost always ships its managed entry point beside it: .dll for or .exe. + var stem = OperatingSystem.IsWindows() ? Path.ChangeExtension(target, extension: null) : target; + File.Exists(stem + ".dll").Should().BeFalse( + $"{target} has a managed entry point beside it, so it is a JIT apphost, not a Native AOT executable"); + } +} +``` + +Don't use `Path.ChangeExtension` on Linux/macOS: it would turn `ProjGraph.Mcp` into `ProjGraph.dll`. + +- [ ] **Step 4: Run the guard against a JIT apphost and watch it fail** + +```bash +dotnet build tests/ProjGraph.Tests.Smoke.Aot -c Release +GITHUB_ACTIONS=true \ +PROJGRAPH_SMOKE_REQUIRED=true \ +PROJGRAPH_SMOKE_CLI_NATIVE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/native/mcp/ProjGraph.Mcp \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build -c Release --filter "FullyQualifiedName~NativeBuildGuardTests" +``` + +Expected: `Failed: 1, Passed: 1`. `CliNative_ShouldBeNativeAotExecutable` fails with `…/ProjGraph.Cli has a managed entry point beside it, so it is a JIT apphost, not a Native AOT executable`, and `McpNative_ShouldBeNativeAotExecutable` passes. + +- [ ] **Step 5: Capture MCP server stderr** + +In `tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs`: + +Add `using System.Collections.Concurrent;` after `using ModelContextProtocol.Client;`. + +Replace the two fields and `GetClientsAsync`: + +```csharp + private McpClient? _native; + private McpClient? _reference; + + /// + /// Connects to both servers on the first call and returns the same clients afterwards. + /// + /// The native and the reference client. + public async Task<(McpClient Native, McpClient Reference)> GetClientsAsync() + { + _native ??= await ConnectAsync(SmokeEnvironment.McpNative); + _reference ??= await ConnectAsync(SmokeEnvironment.McpReference); + return (_native, _reference); + } +``` + +with: + +```csharp + private readonly ConcurrentQueue _nativeStandardError = new(); + private readonly ConcurrentQueue _referenceStandardError = new(); + private McpClient? _native; + private McpClient? _reference; + + /// + /// Connects to both servers on the first call and returns the same clients afterwards. + /// + /// The native and the reference client. + public async Task<(McpClient Native, McpClient Reference)> GetClientsAsync() + { + _native ??= await ConnectAsync(SmokeEnvironment.McpNative, _nativeStandardError); + _reference ??= await ConnectAsync(SmokeEnvironment.McpReference, _referenceStandardError); + return (_native, _reference); + } + + /// + /// Describes everything both servers have written to standard error so far. The servers log + /// warnings and unhandled exceptions there, which the protocol results alone never show. + /// + /// The native and the reference server's standard error, labelled. + public string DescribeStandardError() + { + return $"Native server stderr:\n{string.Join('\n', _nativeStandardError)}\n" + + $"Reference server stderr:\n{string.Join('\n', _referenceStandardError)}"; + } +``` + +Replace `ConnectAsync`: + +```csharp + private static async Task ConnectAsync(SmokeCommand command) + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "ProjGraph AOT smoke", + Command = command.FileName, + Arguments = [.. command.LeadingArguments], + WorkingDirectory = SmokeEnvironment.RepositoryRoot + }); +``` + +with: + +```csharp + private static async Task ConnectAsync(SmokeCommand command, ConcurrentQueue standardError) + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "ProjGraph AOT smoke", + Command = command.FileName, + Arguments = [.. command.LeadingArguments], + WorkingDirectory = SmokeEnvironment.RepositoryRoot, + StandardErrorLines = standardError.Enqueue + }); +``` + +- [ ] **Step 6: Write the stderr to each MCP test's output** + +In `tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs`, add `using Xunit.Abstractions;` after `using System.Text.Json.Nodes;`, then replace: + +```csharp +/// The native and reference servers shared by this class. +public sealed class McpParityTests(McpServerPair servers) : IClassFixture +{ +``` + +with: + +```csharp +/// The native and reference servers shared by this class. +/// Receives both servers' standard error, which xUnit reports with a failing test. +public sealed class McpParityTests(McpServerPair servers, ITestOutputHelper output) + : IClassFixture, IDisposable +{ +``` + +and insert this directly above the first `[AotSmokeFact]` (`Initialize_ShouldAdvertiseTheSameServer`), after the `RequestTimeout` field: + +```csharp + public void Dispose() + { + output.WriteLine(servers.DescribeStandardError()); + } + +``` + +The servers are shared, so the text is cumulative. xUnit shows it only under a failing test's `Standard Output Messages`. + +- [ ] **Step 7: Prove the stderr reaches a failing test** + +```bash +dotnet build tests/ProjGraph.Tests.Smoke.Aot -c Release +printf '#!/bin/sh\necho "boom: native server crashed on startup" >&2\nexit 3\n' > /tmp/fake-mcp && chmod +x /tmp/fake-mcp +GITHUB_ACTIONS=true \ +PROJGRAPH_SMOKE_REQUIRED=true \ +PROJGRAPH_SMOKE_CLI_NATIVE=artifacts/native/cli/ProjGraph.Cli \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE=/tmp/fake-mcp \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build -c Release --filter "FullyQualifiedName~McpParityTests" \ + --logger "console;verbosity=normal" > /tmp/fake-mcp.log 2>&1 +grep -c "Native server stderr:" /tmp/fake-mcp.log +grep -m1 -A2 "Native server stderr:" /tmp/fake-mcp.log +rm /tmp/fake-mcp +``` + +Expected: `Failed: 10`. The first `grep` counts at least 10, and the second prints `Native server stderr:` followed by `boom: native server crashed on startup`. If the log is a one-line summary, the command hook rewrote `dotnet` (see Global Constraints): rerun with `"$(readlink -f "$(command -v dotnet)")" test …`. + +- [ ] **Step 8: Run the whole suite against the real native builds** + +```bash +GITHUB_ACTIONS=true \ +PROJGRAPH_SMOKE_REQUIRED=true \ +PROJGRAPH_SMOKE_CLI_NATIVE=artifacts/native/cli/ProjGraph.Cli \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/native/mcp/ProjGraph.Mcp \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build -c Release +dotnet format ProjGraph.slnx --no-restore --verify-no-changes +``` + +Expected: `Passed: 30, Failed: 0, Skipped: 0`, and format is clean. + +- [ ] **Step 9: Commit** + +```bash +git add tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs +git commit -m "test: guard that smoke builds are native and capture MCP server stderr" +``` + +--- + +### Task 2: Narrow the CLI's IL3050 suppression + +PR 2 put `[UnconditionalSuppressMessage("AOT", "IL3050")]` on all of `Program.Main`. Only `new CommandApp(registrar)` needs it, so it moves to a one-line helper, and a future `RequiresDynamicCode` call elsewhere in `Main` will fail the build again. + +**Files:** +- Modify: `src/ProjGraph.Cli/Program.cs` + +**Interfaces:** +- Consumes: `TypeRegistrar` (`ProjGraph.Cli.Infrastructure`), `Spectre.Console.Cli.CommandApp`. +- Produces: `private static CommandApp CreateApp(TypeRegistrar registrar)`, used only by `Main`. + +- [ ] **Step 1: Move the construction into `CreateApp` without the attribute and watch the build fail** + +In `src/ProjGraph.Cli/Program.cs`: + +1. Delete the four-line `[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", …)]` attribute above `public static int Main(string[] args)`, and keep the text of its `Justification` for Step 3. +2. Replace: + +```csharp + var registrar = new TypeRegistrar(services); + var app = new CommandApp(registrar); +``` + +with: + +```csharp + var app = CreateApp(new TypeRegistrar(services)); +``` + +3. After the closing brace of `Main`, still inside the class, add: + +```csharp + + private static CommandApp CreateApp(TypeRegistrar registrar) + { + return new CommandApp(registrar); + } +``` + +Run: + +```bash +dotnet build src/ProjGraph.Cli -c Release --no-incremental +``` + +Expected: FAIL with `Program.cs(…): error IL3050: Using member 'Spectre.Console.Cli.CommandApp.CommandApp(ITypeRegistrar)' which has 'RequiresDynamicCodeAttribute'…`, pointing at the `return new CommandApp(registrar);` line. That proves the analyzer runs on every build and that nothing else in `Main` needs the suppression. + +- [ ] **Step 2: Put the suppression on `CreateApp`** + +Replace the helper with: + +```csharp + + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", + Justification = "Spectre.Console.Cli reflects over the command and settings types. ProjGraph.Cli and " + + "Spectre.Console.Cli are trimmer root assemblies, so that metadata is kept, and " + + "tests/ProjGraph.Tests.Smoke.Aot runs every command natively.")] + private static CommandApp CreateApp(TypeRegistrar registrar) + { + return new CommandApp(registrar); + } +``` + +`using System.Diagnostics.CodeAnalysis;` is already at the top of the file. + +- [ ] **Step 3: Verify** + +```bash +dotnet build ProjGraph.slnx -c Release +dotnet test tests/ProjGraph.Tests.Integration.Cli -c Release --no-build +dotnet format ProjGraph.slnx --no-restore --verify-no-changes +``` + +Expected: 0 warnings, 0 errors, all `Tests.Integration.Cli` tests pass, and format is clean. Task 4 runs the CLI natively with this change. + +- [ ] **Step 4: Commit** + +```bash +git add src/ProjGraph.Cli/Program.cs +git commit -m "refactor(cli): narrow the IL3050 suppression to CommandApp construction" +``` + +--- + +### Task 3: RID-specific Native AOT tool packages + +The two tool projects become RID-specific AOT tool packages. The MCP server stops being self-contained, which moves its apphost, so the MCP integration helper and the `aot-smoke` reference path follow. + +**Files:** +- Modify: `src/ProjGraph.Cli/ProjGraph.Cli.csproj` +- Modify: `src/ProjGraph.Mcp/ProjGraph.Mcp.csproj` +- Modify: `tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs` +- Modify: `.github/workflows/ci.yml` (one path; Task 4 replaces the job) +- Modify: `src/ProjGraph.Cli/README.md`, `src/ProjGraph.Mcp/README.md`, `docfx/guides/getting-started.md`, `CLAUDE.md` + +**Interfaces:** +- Produces: + - Pointer packages `ProjGraph.Cli..nupkg` and `ProjGraph.Mcp..nupkg` from `dotnet pack` (no `-r`). + - Native packages `ProjGraph....nupkg` from `dotnet pack -r `. + - Fallback packages `ProjGraph..any..nupkg` from `dotnet pack -r any -p:PublishAot=false`. + - JIT reference paths: `src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll` (unchanged) and `src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll`/`ProjGraph.Mcp[.exe]` (no longer under `linux-x64/`). + - Installed command names: `projgraph` and `ProjGraph.Mcp`. + +- [ ] **Step 1: Start from a clean MCP build output** + +A leftover self-contained apphost under `src/ProjGraph.Mcp/bin/Release/net10.0//` would let the old helper pass in Step 3 and hide the break: + +```bash +rm -rf src/ProjGraph.Mcp/bin src/ProjGraph.Mcp/obj +``` + +- [ ] **Step 2: Configure both tool projects** + +In `src/ProjGraph.Mcp/ProjGraph.Mcp.csproj`, replace: + +```xml + + true + true + + + true + + +``` + +with: + +```xml + + true + win-x64;linux-x64;linux-arm64;linux-musl-x64;linux-musl-arm64;osx-arm64;any + + false + + false + + +``` + +In `src/ProjGraph.Cli/ProjGraph.Cli.csproj`, replace: + +```xml + + LatestMajor + + +``` + +with: + +```xml + + LatestMajor + + + true + win-x64;linux-x64;linux-arm64;linux-musl-x64;linux-musl-arm64;osx-arm64;any + + false + + false + + +``` + +Leave everything else in both files as it is: `EnableTrimAnalyzer`, `EnableAotAnalyzer`, `IlcTreatWarningsAsErrors`, `JsonSerializerIsReflectionEnabledByDefault`, the trimmer roots, and the package metadata. + +- [ ] **Step 3: Run the MCP integration tests and watch the transport tests fail** + +```bash +dotnet build ProjGraph.slnx -c Release +dotnet test tests/ProjGraph.Tests.Integration.Mcp -c Release --no-build +``` + +Expected: the build has 0 warnings. The tests end with `Failed: 4`, all in `McpTransportTests`, each with `Expected serverExe not to be because the MCP server apphost must be present under …/src/ProjGraph.Mcp/bin/Release/net10.0.` The framework-dependent apphost now sits in that folder itself, and the helper only probes subfolders. + +- [ ] **Step 4: Launch the apphost from the build output folder** + +In `tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs`, replace the `ConnectAsync` summary: + +```csharp + /// + /// Connects a client to the server apphost in ProjGraph.Mcp's own build output (guaranteed + /// up to date by the ProjectReference). ProjGraph.Mcp is a self-contained exe, so its build + /// lands in a RID subdirectory and must be launched via its apphost — the DLL that the + /// ProjectReference copies into the test output has no runtime next to it and cannot start. + /// The client deliberately advertises no capabilities — in particular no workspace roots. + /// +``` + +with: + +```csharp + /// + /// Connects a client to the server apphost in ProjGraph.Mcp's own build output (guaranteed + /// up to date by the ProjectReference). ProjGraph.Mcp builds framework-dependent, so the apphost + /// sits directly in bin/{Configuration}/{tfm}/; RID subdirectories hold + /// dotnet pack -r or dotnet publish -r output and are ignored. + /// The client deliberately advertises no capabilities — in particular no workspace roots. + /// +``` + +and replace the body of `LocateServerExecutable` from `Directory.Exists(binRoot).Should()…` to the end of the method: + +```csharp + Directory.Exists(binRoot).Should().BeTrue( + $"the MCP server build output must exist at {binRoot}"); + var exeName = OperatingSystem.IsWindows() ? "ProjGraph.Mcp.exe" : "ProjGraph.Mcp"; + + // The build RID matches the machine that built it, so probing the RID subdirectories + // is exact enough without reconstructing the RID by hand. Preferring the most recently + // written apphost keeps a dev machine with stale cross-RID leftovers deterministic. + var serverExe = Directory.GetDirectories(binRoot) + .Select(ridDir => new FileInfo(Path.Combine(ridDir, exeName))) + .Where(apphost => apphost.Exists) + .OrderByDescending(apphost => apphost.LastWriteTimeUtc) + .FirstOrDefault(); + + serverExe.Should().NotBeNull($"the MCP server apphost must be present under {binRoot}"); + return serverExe.FullName; + } +``` + +with: + +```csharp + var exeName = OperatingSystem.IsWindows() ? "ProjGraph.Mcp.exe" : "ProjGraph.Mcp"; + var serverExe = new FileInfo(Path.Combine(binRoot, exeName)); + + serverExe.Exists.Should().BeTrue($"the MCP server apphost must be present at {serverExe.FullName}"); + return serverExe.FullName; + } +``` + +- [ ] **Step 5: Run the whole suite** + +```bash +dotnet build ProjGraph.slnx -c Release +dotnet test ProjGraph.slnx -c Release --no-build +dotnet format ProjGraph.slnx --no-restore --verify-no-changes +``` + +Expected: `Tests.Integration.Mcp` passes 150/150, every other suite is green (1,178 passed in total), `Tests.Smoke.Aot` shows `Skipped: 30`, and format is clean. + +- [ ] **Step 6: Check the pointer, fallback, and native package contents** + +```bash +rm -rf artifacts/pkgcheck src/ProjGraph.Cli/bin/Release/net10.0/linux-x64 src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64 +dotnet build ProjGraph.slnx -c Release -p:Version=0.0.0-local.1 +dotnet pack ProjGraph.slnx --no-build -c Release -p:Version=0.0.0-local.1 -o artifacts/pkgcheck/solution +dotnet pack src/ProjGraph.Cli -c Release -r any -p:PublishAot=false -p:Version=0.0.0-local.1 -o artifacts/pkgcheck/any +dotnet pack src/ProjGraph.Mcp -c Release -r any -p:PublishAot=false -p:Version=0.0.0-local.1 -o artifacts/pkgcheck/any +dotnet pack src/ProjGraph.Cli -c Release -r linux-x64 -p:Version=0.0.0-local.1 -o artifacts/pkgcheck/native +dotnet pack src/ProjGraph.Mcp -c Release -r linux-x64 -p:Version=0.0.0-local.1 -o artifacts/pkgcheck/native +ls -la artifacts/pkgcheck/* +unzip -l artifacts/pkgcheck/solution/ProjGraph.Mcp.0.0.0-local.1.nupkg +unzip -p artifacts/pkgcheck/solution/ProjGraph.Cli.0.0.0-local.1.nupkg tools/any/any/DotnetToolSettings.xml +unzip -l artifacts/pkgcheck/native/ProjGraph.Cli.linux-x64.0.0.0-local.1.nupkg +grep -c IsDynamicCodeSupported src/ProjGraph.Cli/bin/Release/net10.0/any/ProjGraph.Cli.runtimeconfig.json || true +``` + +(Run the two `-r linux-x64` packs one at a time; each takes about a minute.) + +Expected: +- `solution/` holds the 6 library `.nupkg` + 6 `.snupkg` and exactly two tool packages, `ProjGraph.Cli.0.0.0-local.1.nupkg` and `ProjGraph.Mcp.0.0.0-local.1.nupkg`, with no tool `.snupkg` and no `NU5017` error. +- The MCP pointer lists `.mcp/server.json`, `README.md`, `icon.png`, and `tools/any/any/DotnetToolSettings.xml`. The CLI pointer's settings list 7 `RuntimeIdentifierPackage` entries, one per RID including `any`. +- `any/` holds `ProjGraph.Cli.any.0.0.0-local.1.nupkg` (~8 MB) and `ProjGraph.Mcp.any.0.0.0-local.1.nupkg`. +- `native/` holds `ProjGraph.Cli.linux-x64.0.0.0-local.1.nupkg` (~15 MB) and `ProjGraph.Mcp.linux-x64.0.0.0-local.1.nupkg` (~17 MB), and no `.snupkg`. The CLI package lists `tools/any/linux-x64/ProjGraph.Cli` and **no** `.dbg` file. +- The `grep -c` prints `0`: the fallback build has no AOT feature switches. + +- [ ] **Step 7: Check that installing from the packages resolves the native package** + +```bash +rm -rf artifacts/pkgcheck/feed artifacts/pkgcheck/tools artifacts/pkgcheck/nuget +mkdir -p artifacts/pkgcheck/feed +cp artifacts/pkgcheck/solution/ProjGraph.Cli.0.0.0-local.1.nupkg artifacts/pkgcheck/native/ProjGraph.Cli.linux-x64.0.0.0-local.1.nupkg artifacts/pkgcheck/feed/ +printf '\n\n' > artifacts/pkgcheck/feed/nuget.config +NUGET_PACKAGES="$PWD/artifacts/pkgcheck/nuget" dotnet tool install ProjGraph.Cli --version 0.0.0-local.1 --configfile artifacts/pkgcheck/feed/nuget.config --tool-path artifacts/pkgcheck/tools +ls -la artifacts/pkgcheck/tools +artifacts/pkgcheck/tools/projgraph stats ProjGraph.slnx | head -3 +``` + +Expected: the install succeeds. `projgraph -> .store/projgraph.cli/0.0.0-local.1/projgraph.cli.linux-x64/0.0.0-local.1/tools/any/linux-x64/ProjGraph.Cli`, and `stats` prints the `── ProjGraph.slnx ──` header. + +Then check Alpine resolution, if Docker is available (skip this with a note in the task report if it isn't; Task 4 Step 5 covers musl end to end): + +```bash +docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp/home -v "$PWD/artifacts/pkgcheck/feed:/feed:ro" \ + mcr.microsoft.com/dotnet/sdk:10.0-alpine \ + sh -c 'mkdir -p /tmp/home && dotnet tool install ProjGraph.Cli --version 0.0.0-local.1 --configfile /feed/nuget.config --tool-path /tmp/t; echo "exit=$?"' +``` + +Expected: the install **fails** with `Version 0.0.0-local.1 of package projgraph.cli.linux-musl-x64 is not found`. Alpine now asks for the musl package, and only the linux-x64 one is in this feed. (Before this change it silently installed the glibc `linux-x64` package, which then couldn't start.) + +```bash +rm -rf artifacts/pkgcheck +``` + +- [ ] **Step 8: Point `aot-smoke` at the moved MCP reference** + +In `.github/workflows/ci.yml`, in the `Smoke test native executables` step, replace: + +```yaml + PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll +``` + +with: + +```yaml + PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll +``` + +This keeps the job working at this commit; Task 4 replaces the job. + +- [ ] **Step 9: Document installation** + +In `src/ProjGraph.Cli/README.md`, replace: + +````markdown +## Installation + +```bash +dotnet tool install -g ProjGraph.Cli +``` +```` + +with: + +````markdown +## Installation + +```bash +dotnet tool install -g ProjGraph.Cli +``` + +Installing needs the [.NET 10 SDK](https://dotnet.microsoft.com/download) or later. On Windows x64, Linux x64 and +Arm64 (glibc or musl), and macOS Arm64, the tool is a Native AOT executable that starts without loading the .NET +runtime. Other platforms get a framework-dependent build that runs on the .NET 10 runtime. +```` + +In `src/ProjGraph.Mcp/README.md`, replace: + +````markdown +```json +{ + "servers": { + "ProjGraph.Mcp": { + "type": "stdio", + "command": "dnx", + "args": ["ProjGraph.Mcp@x.x.x", "--yes"] + } + } +} +``` + +Or run from source: +```` + +with: + +````markdown +```json +{ + "servers": { + "ProjGraph.Mcp": { + "type": "stdio", + "command": "dnx", + "args": ["ProjGraph.Mcp@x.x.x", "--yes"] + } + } +} +``` + +`dnx` ships with the [.NET 10 SDK](https://dotnet.microsoft.com/download). On Windows x64, Linux x64 and Arm64 (glibc +or musl), and macOS Arm64, it runs a Native AOT build of the server that starts without loading the .NET runtime. +Other platforms get a framework-dependent build that runs on the .NET 10 runtime. + +Or run from source: +```` + +Leave the `` comment as the last line of that README. + +In `docfx/guides/getting-started.md`, replace: + +```markdown +ProjGraph reads source files directly. You do not need to build the project you +are analysing, run a database, or apply migrations first. +``` + +with: + +```markdown +On Windows x64, Linux x64 and Arm64 (glibc or musl), and macOS Arm64, both the +CLI and the MCP server install as Native AOT executables that start without +loading the .NET runtime. Other platforms get a framework-dependent build that +runs on the runtime the SDK includes. + +ProjGraph reads source files directly. You do not need to build the project you +are analysing, run a database, or apply migrations first. +``` + +In `CLAUDE.md`, under `### Key patterns`, insert after the `**MCP stdout safety** — …` paragraph: + +```markdown +**Tool packaging** — `ProjGraph.Cli` and `ProjGraph.Mcp` set `PublishAot=true` and `ToolPackageRuntimeIdentifiers`, so a plain `dotnet pack` builds only the pointer package. Each native package needs `dotnet pack -r ` on a matching OS (Alpine for `linux-musl-*`), and the `any` fallback needs `dotnet pack -r any -p:PublishAot=false`. Because `PublishAot` is set, plain JIT builds also get AOT feature switches (e.g. `IsDynamicCodeSupported=false`) in their `runtimeconfig.json`. `.github/workflows/pack.yml` builds every package. +``` + +- [ ] **Step 10: Commit** + +```bash +git add src/ProjGraph.Cli/ProjGraph.Cli.csproj src/ProjGraph.Mcp/ProjGraph.Mcp.csproj tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs .github/workflows/ci.yml src/ProjGraph.Cli/README.md src/ProjGraph.Mcp/README.md docfx/guides/getting-started.md CLAUDE.md +git commit -m "feat: pack the CLI and MCP server as RID-specific Native AOT tools" +``` + +--- + +### Task 4: Native tool smoke script, composite action, and `aot-smoke` + +One script runs the whole per-RID check: build → native pack → install from packages → smoke suite. A composite action runs it on the runner, or inside Alpine for musl, and `aot-smoke` switches to it. + +**Files:** +- Create: `.github/scripts/native-tool-smoke.sh` +- Create: `.github/actions/native-tool-smoke/action.yml` +- Modify: `.github/workflows/ci.yml` +- Modify: `CONTRIBUTING.md`, `CLAUDE.md` + +**Interfaces:** +- Consumes: Task 1's 30-test suite with `PROJGRAPH_SMOKE_*`, and Task 3's package commands and paths. +- Produces: + - The script `.github/scripts/native-tool-smoke.sh `, run from the repository root. It leaves `artifacts/packages/ProjGraph.{Cli,Mcp}...nupkg`, `artifacts/pointers/ProjGraph.{Cli,Mcp}..nupkg`, and `artifacts/smoke-results/smoke.trx`. + - The composite action `./.github/actions/native-tool-smoke` with inputs `rid` and `version`. It needs only a checkout beforehand, because it sets up .NET itself. + +- [ ] **Step 1: Write the script** + +Create `.github/scripts/native-tool-smoke.sh`: + +```bash +#!/usr/bin/env bash +# Builds the solution, packs the Native AOT ProjGraph.Cli and ProjGraph.Mcp tool packages for one +# runtime identifier, installs both tools from those packages the way users do, and runs +# tests/ProjGraph.Tests.Smoke.Aot against the JIT build of the same commit. +# Usage: native-tool-smoke.sh +# Run it from the repository root, on an OS that matches the RID (Alpine for linux-musl-*). +# Leaves the RID-specific packages in artifacts/packages, the pointer packages in artifacts/pointers, +# and the test results in artifacts/smoke-results. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +rid=$1 +version=$2 + +dotnet restore ProjGraph.slnx +dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$version" + +# The pointer packages are only needed to install the tools below; a release publishes the ones +# from the pack-portable job. +dotnet pack src/ProjGraph.Cli --no-build --configuration Release -p:Version="$version" --output artifacts/pointers +dotnet pack src/ProjGraph.Mcp --no-build --configuration Release -p:Version="$version" --output artifacts/pointers + +# One native pack at a time: each ILC run takes several GB of memory. +dotnet pack src/ProjGraph.Cli --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages +dotnet pack src/ProjGraph.Mcp --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages + +# Only the local packages are sources, so nothing resolves from NuGet.org, and a fresh package folder +# keeps a cached copy of the same version from being installed instead. +cat > artifacts/smoke-nuget.config <<'XML' + + + + + + + + +XML +rm -rf artifacts/tools artifacts/tool-install-packages +for tool in Cli Mcp; do + NUGET_PACKAGES="$PWD/artifacts/tool-install-packages" dotnet tool install "ProjGraph.$tool" --version "$version" \ + --configfile artifacts/smoke-nuget.config --tool-path "artifacts/tools/$tool" +done + +exe='' +mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll +if [ "${RUNNER_OS:-}" = 'Windows' ]; then + exe='.exe' + # The MCP SDK starts stdio servers through `cmd.exe /c` on Windows, which is fragile with a quoted + # dotnet host path. The JIT apphost runs the same build without one. + mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.exe +fi + +PROJGRAPH_SMOKE_REQUIRED=true \ +PROJGRAPH_SMOKE_CLI_NATIVE="artifacts/tools/Cli/projgraph$exe" \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE="artifacts/tools/Mcp/ProjGraph.Mcp$exe" \ +PROJGRAPH_SMOKE_MCP_REFERENCE="$mcp_reference" \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ + --logger "console;verbosity=normal" --logger "trx;LogFileName=smoke.trx" \ + --results-directory artifacts/smoke-results +``` + +Mark it executable in git, so it also runs as `./.github/scripts/native-tool-smoke.sh`. The workflows call it through `bash`, so Windows doesn't depend on the bit. + +```bash +chmod +x .github/scripts/native-tool-smoke.sh +git add .github/scripts/native-tool-smoke.sh +git update-index --chmod=+x .github/scripts/native-tool-smoke.sh +docker run --rm --user "$(id -u):$(id -g)" --entrypoint shellcheck -v "$PWD":/s -w /s rhysd/actionlint:latest .github/scripts/native-tool-smoke.sh +``` + +Expected: shellcheck prints nothing and exits 0. + +- [ ] **Step 2: Write the composite action** + +Create `.github/actions/native-tool-smoke/action.yml`: + +```yaml +name: Native tool smoke test +description: >- + Runs .github/scripts/native-tool-smoke.sh for one runtime identifier: builds the solution, packs the + Native AOT ProjGraph.Cli and ProjGraph.Mcp tool packages, installs both tools from them, and runs + tests/ProjGraph.Tests.Smoke.Aot against the JIT build of the same commit. linux-musl-* RIDs run + inside an Alpine .NET SDK container. Expects the repository to be checked out. + +inputs: + rid: + description: Runtime identifier to pack. The runner must match its OS and architecture (Linux for linux-musl-*). + required: true + version: + description: Package version, passed as -p:Version to every build and pack. + required: true + +runs: + using: composite + steps: + - name: Setup .NET + if: ${{ !startsWith(inputs.rid, 'linux-musl-') }} + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props + + - name: Install Native AOT prerequisites + if: ${{ runner.os == 'Linux' && !startsWith(inputs.rid, 'linux-musl-') }} + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + - name: Pack and smoke test + if: ${{ !startsWith(inputs.rid, 'linux-musl-') }} + shell: bash + env: + RID: ${{ inputs.rid }} + VERSION: ${{ inputs.version }} + run: bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION" + + # musl binaries must be linked on a musl system. The container runs as root, so the files it + # writes are handed back to the runner user even when the script fails. + - name: Pack and smoke test in Alpine + if: ${{ startsWith(inputs.rid, 'linux-musl-') }} + shell: bash + env: + RID: ${{ inputs.rid }} + VERSION: ${{ inputs.version }} + run: | + docker run --rm --volume "$PWD:/src" --workdir /src \ + --env RID --env VERSION --env GITHUB_ACTIONS --env DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + mcr.microsoft.com/dotnet/sdk:10.0-alpine \ + sh -c 'apk add --no-cache bash clang build-base zlib-dev && + status=0 && + { bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION" || status=$?; } && + chown -R "$(stat -c %u:%g /src)" /src && + exit "$status"' + + - name: Upload smoke test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-results-${{ inputs.rid }} + path: artifacts/smoke-results + if-no-files-found: ignore + retention-days: 14 +``` + +- [ ] **Step 3: Switch `aot-smoke` to the action** + +In `.github/workflows/ci.yml`, replace the whole `aot-smoke` job with: + +```yaml + aot-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + + # The same pack → install → smoke path that pack.yml runs for every release platform. + - name: Pack and smoke test the native tools + uses: ./.github/actions/native-tool-smoke + with: + rid: linux-x64 + version: 0.0.0-ci.${{ github.run_number }} +``` + +Lint both files: + +```bash +docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false .github/workflows/ci.yml +``` + +Expected: no output, exit 0. actionlint also validates the local action's inputs. + +- [ ] **Step 4: Run the script on linux-x64 from a clean clone** + +A clone only sees committed state, so commit first. Keep `GITHUB_ACTIONS=true`, because Spectre.Console turns on ANSI output under it (a PR 2 lesson): + +```bash +git add .github/actions/native-tool-smoke/action.yml .github/workflows/ci.yml +git commit -m "ci: smoke-test native tools through their packages" +rm -rf /tmp/pg-pr3-host && git clone --quiet --branch feat/aot-packaging . /tmp/pg-pr3-host +cd /tmp/pg-pr3-host +GITHUB_ACTIONS=true bash .github/scripts/native-tool-smoke.sh linux-x64 0.0.0-local.2 +ls artifacts/packages artifacts/pointers +ls -la artifacts/tools/Cli artifacts/tools/Mcp +cd - && rm -rf /tmp/pg-pr3-host +``` + +Expected: +- The run ends with `Passed: 30, Failed: 0, Skipped: 0`. +- `artifacts/packages` holds `ProjGraph.Cli.linux-x64.0.0.0-local.2.nupkg` and `ProjGraph.Mcp.linux-x64.0.0.0-local.2.nupkg`. +- `artifacts/pointers` holds the two pointer packages. +- `projgraph` and `ProjGraph.Mcp` are symlinks into `.store/…linux-x64…`. + +- [ ] **Step 5: Run the script for linux-musl-x64 in Alpine, exactly as the action does** + +This takes several minutes: the container restores every package from NuGet.org and runs two native packs. Don't run it at the same time as Step 4. + +```bash +rm -rf /tmp/pg-pr3-musl && git clone --quiet --branch feat/aot-packaging . /tmp/pg-pr3-musl +cd /tmp/pg-pr3-musl +RID=linux-musl-x64 VERSION=0.0.0-local.3 GITHUB_ACTIONS=true \ +docker run --rm --volume "$PWD:/src" --workdir /src \ + --env RID --env VERSION --env GITHUB_ACTIONS --env DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + mcr.microsoft.com/dotnet/sdk:10.0-alpine \ + sh -c 'apk add --no-cache bash clang build-base zlib-dev && + status=0 && + { bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION" || status=$?; } && + chown -R "$(stat -c %u:%g /src)" /src && + exit "$status"' +echo "exit=$?" +ls -la artifacts/packages +cd - && rm -rf /tmp/pg-pr3-musl +``` + +Expected: `Passed: 30, Failed: 0, Skipped: 0`, `exit=0`, and `ProjGraph.{Cli,Mcp}.linux-musl-x64.0.0.0-local.3.nupkg` owned by your user, not root. If this machine can't run Docker, say so in the task report; Task 7's PR run then provides the first musl evidence. + +- [ ] **Step 6: Update the contributor docs** + +In `CONTRIBUTING.md`, replace the `Tests.Smoke.Aot` row of the test table with the row below, and re-pad the table's columns so the `|` separators stay aligned: + +```markdown +| `Tests.Smoke.Aot` | Native AOT vs JIT parity for the CLI and MCP tools (skipped unless `PROJGRAPH_SMOKE_*` is set; CI runs it through `.github/actions/native-tool-smoke`) | +``` + +Then replace the whole `### Native AOT smoke tests` section, up to the next `## Adding a New Feature` heading, with: + +````markdown +### Native AOT smoke tests + +`Tests.Smoke.Aot` compares Native AOT builds of the CLI and MCP server with the JIT build of the +same commit. It is skipped unless the `PROJGRAPH_SMOKE_*` variables are set. CI runs +`.github/scripts/native-tool-smoke.sh` through the `.github/actions/native-tool-smoke` action: it +packs the native tool packages, installs them with `dotnet tool install`, and tests the installed +tools. The `aot-smoke` job does this for linux-x64 on every PR, and `pack.yml` does it for every +release platform. Native AOT on Linux needs `clang` (or `gcc`) and `zlib1g-dev`. + +To run the same check locally on Linux: + +```bash +bash .github/scripts/native-tool-smoke.sh linux-x64 0.0.0-local.1 +``` + +For a quicker loop, test published binaries instead (run one publish at a time; each takes several GB +of memory): + +```bash +dotnet build ProjGraph.slnx -c Release +dotnet publish src/ProjGraph.Cli -c Release -r linux-x64 -o artifacts/native/cli +dotnet publish src/ProjGraph.Mcp -c Release -r linux-x64 -o artifacts/native/mcp +PROJGRAPH_SMOKE_CLI_NATIVE=artifacts/native/cli/ProjGraph.Cli \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/native/mcp/ProjGraph.Mcp \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot -c Release --no-build +``` +```` + +In `CLAUDE.md`, replace the `Tests.Smoke.Aot` row of the test projects table with: + +```markdown +| `Tests.Smoke.Aot` | Native AOT vs JIT parity for the installed CLI and MCP tools (skipped unless `PROJGRAPH_SMOKE_*` is set; `.github/scripts/native-tool-smoke.sh` runs it in the `aot-smoke` CI job and for every RID in `pack.yml`) | +``` + +- [ ] **Step 7: Verify the quick publish loop from CONTRIBUTING** + +Run the second code block above from the repository, one publish at a time. Record hashes of the JIT build first, so the check also proves an in-place `-r linux-x64` publish leaves the JIT reference alone: + +```bash +dotnet build ProjGraph.slnx -c Release +find src/ProjGraph.Cli/bin/Release/net10.0 src/ProjGraph.Mcp/bin/Release/net10.0 -maxdepth 1 -type f -exec sha256sum {} + | sort > /tmp/jit-before.txt +dotnet publish src/ProjGraph.Cli -c Release -r linux-x64 -o artifacts/native/cli +dotnet publish src/ProjGraph.Mcp -c Release -r linux-x64 -o artifacts/native/mcp +find src/ProjGraph.Cli/bin/Release/net10.0 src/ProjGraph.Mcp/bin/Release/net10.0 -maxdepth 1 -type f -exec sha256sum {} + | sort > /tmp/jit-after.txt +diff /tmp/jit-before.txt /tmp/jit-after.txt && echo "JIT build untouched" +PROJGRAPH_SMOKE_CLI_NATIVE=artifacts/native/cli/ProjGraph.Cli \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/native/mcp/ProjGraph.Mcp \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot -c Release --no-build +rm -f /tmp/jit-before.txt /tmp/jit-after.txt +``` + +Expected: `JIT build untouched`, `Passed: 30`, and no `.dbg` in `artifacts/native/cli`. If the diff isn't empty, don't document the in-place publish: restore `--artifacts-path artifacts/aot-build` in both commands, both here and in `CONTRIBUTING.md`. + +- [ ] **Step 8: Commit** + +```bash +git add CONTRIBUTING.md CLAUDE.md +git commit -m "docs: describe the package-based Native AOT smoke check" +``` + +--- + +### Task 5: `pack.yml` and the package-set check + +The reusable workflow that builds every release package. A strict package-set check in each job catches a missing or extra package before `publish` sees it. + +**Files:** +- Create: `.github/scripts/verify-packages.sh` +- Create: `.github/workflows/pack.yml` + +**Interfaces:** +- Consumes: the composite action from Task 4, and Task 3's pack commands. +- Produces: + - Workflow `./.github/workflows/pack.yml` with `workflow_call` inputs `ref` (string, required) and `version` (string, required). + - Artifacts `packages-` for each of the six RIDs, each holding two `.nupkg`. + - Artifact `packages-portable`: 6 library `.nupkg` + 6 `.snupkg` + 2 `any` `.nupkg`. + - Artifact `pointers`: the 2 pointer `.nupkg`. + - The script `.github/scripts/verify-packages.sh ...` with sets `libraries`, `pointers`, `any`, and each of the six RIDs. + +- [ ] **Step 1: Write the package-set check** + +Create `.github/scripts/verify-packages.sh`: + +```bash +#!/usr/bin/env bash +# Fails unless a directory holds exactly the .nupkg files of the named package sets. +# Usage: verify-packages.sh ... +# Sets: libraries, pointers, any, win-x64, linux-x64, linux-arm64, linux-musl-x64, linux-musl-arm64, osx-arm64. +# Keep it Bash 3.2 compatible: the osx-arm64 pack job runs it on macOS. +set -euo pipefail + +if [ "$#" -lt 3 ]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +directory=$1 +version=$2 +shift 2 + +expected="" +add() { + expected="$expected$1.$version.nupkg"$'\n' +} + +for set in "$@"; do + case "$set" in + libraries) + for id in ProjGraph.Core ProjGraph.Lib ProjGraph.Lib.Core ProjGraph.Lib.Dependencies \ + ProjGraph.Lib.ClassDiagram ProjGraph.Lib.EntityFramework; do + add "$id" + done + ;; + pointers) + add ProjGraph.Cli + add ProjGraph.Mcp + ;; + any | win-x64 | linux-x64 | linux-arm64 | linux-musl-x64 | linux-musl-arm64 | osx-arm64) + add "ProjGraph.Cli.$set" + add "ProjGraph.Mcp.$set" + ;; + *) + echo "::error::Unknown package set '$set'." >&2 + exit 2 + ;; + esac +done + +expected=$(printf '%s' "$expected" | sort) +actual=$(for package in "$directory"/*.nupkg; do + if [ -e "$package" ]; then basename "$package"; fi +done | sort) + +if [ "$actual" != "$expected" ]; then + echo "::error::$directory doesn't hold exactly the packages for: $*" >&2 + diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true + exit 1 +fi + +echo "$directory holds the $(printf '%s\n' "$actual" | wc -l | tr -d ' ') expected packages." +``` + +```bash +chmod +x .github/scripts/verify-packages.sh +git add .github/scripts/verify-packages.sh +git update-index --chmod=+x .github/scripts/verify-packages.sh +docker run --rm --user "$(id -u):$(id -g)" --entrypoint shellcheck -v "$PWD":/s -w /s rhysd/actionlint:latest .github/scripts/verify-packages.sh +``` + +- [ ] **Step 2: Test the check against fake package folders** + +```bash +rm -rf /tmp/vp && mkdir -p /tmp/vp/ok /tmp/vp/pointers /tmp/vp/empty +for id in ProjGraph.Core ProjGraph.Lib ProjGraph.Lib.Core ProjGraph.Lib.Dependencies ProjGraph.Lib.ClassDiagram ProjGraph.Lib.EntityFramework ProjGraph.Cli.any ProjGraph.Mcp.any; do touch "/tmp/vp/ok/$id.1.2.3-rc.1.nupkg"; done +touch /tmp/vp/ok/ProjGraph.Core.1.2.3-rc.1.snupkg /tmp/vp/pointers/ProjGraph.Cli.1.2.3-rc.1.nupkg /tmp/vp/pointers/ProjGraph.Mcp.1.2.3-rc.1.nupkg +bash .github/scripts/verify-packages.sh /tmp/vp/ok 1.2.3-rc.1 libraries any; echo "1 exit=$? (want 0)" +bash .github/scripts/verify-packages.sh /tmp/vp/pointers 1.2.3-rc.1 pointers; echo "2 exit=$? (want 0)" +bash .github/scripts/verify-packages.sh /tmp/vp/ok 1.2.3-rc.1 libraries any linux-musl-x64; echo "3 exit=$? (want 1, diff lists the two musl packages)" +bash .github/scripts/verify-packages.sh /tmp/vp/ok 1.2.3-rc.1 libraries; echo "4 exit=$? (want 1, diff lists the two extra any packages)" +bash .github/scripts/verify-packages.sh /tmp/vp/empty 1.2.3-rc.1 pointers; echo "5 exit=$? (want 1)" +bash .github/scripts/verify-packages.sh /tmp/vp/ok 1.2.3-rc.1 bogus; echo "6 exit=$? (want 2)" +rm -rf /tmp/vp +``` + +Expected: exit codes 0, 0, 1, 1, 1, 2. Runs 1–2 print `… holds the 8 expected packages.` and `… holds the 2 expected packages.`; the `.snupkg` doesn't count. + +- [ ] **Step 3: Write the workflow** + +Create `.github/workflows/pack.yml`: + +```yaml +name: Pack + +# Builds every package a release ships: two Native AOT tool packages per runtime identifier, each +# smoke-tested on a matching runner, plus the libraries, the framework-dependent fallback packages, +# and the pointer packages. publish.yml calls it for a tag. It also runs on pull requests that +# change packaging, so every native build is proven before a release depends on it. +on: + workflow_call: + inputs: + ref: + description: Git ref to check out (the release tag). + required: true + type: string + version: + description: Package version. + required: true + type: string + pull_request: + paths: + - .github/workflows/pack.yml + - .github/actions/native-tool-smoke/** + - .github/scripts/native-tool-smoke.sh + - .github/scripts/verify-packages.sh + - src/ProjGraph.Cli/ProjGraph.Cli.csproj + - src/ProjGraph.Mcp/ProjGraph.Mcp.csproj + - tests/ProjGraph.Tests.Smoke.Aot/** + - Directory.Build.props + - Directory.Packages.props + - global.json + +permissions: + contents: read + +concurrency: + group: pack-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Pull request runs have no version input; any version works as long as every command uses it. + VERSION: ${{ inputs.version || format('0.0.0-ci.{0}', github.run_number) }} + +jobs: + pack-native: + name: pack-native (${{ matrix.rid }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + runner: windows-latest + - rid: linux-x64 + runner: ubuntu-latest + - rid: linux-arm64 + runner: ubuntu-24.04-arm + - rid: linux-musl-x64 + runner: ubuntu-latest + - rid: linux-musl-arm64 + runner: ubuntu-24.04-arm + - rid: osx-arm64 + runner: macos-latest + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Pack and smoke test the native tools + uses: ./.github/actions/native-tool-smoke + with: + rid: ${{ matrix.rid }} + version: ${{ env.VERSION }} + + - name: Verify the native packages + shell: bash + env: + RID: ${{ matrix.rid }} + run: bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" "$RID" + + - name: Upload native packages + uses: actions/upload-artifact@v7 + with: + name: packages-${{ matrix.rid }} + path: artifacts/packages/*.nupkg + if-no-files-found: error + retention-days: 7 + + pack-portable: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props + + - name: Build + run: | + dotnet restore ProjGraph.slnx + dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" + + # With PublishAot set, packing the solution produces the RID-agnostic pointer packages for the + # two tools; they move to their own folder because publish.yml pushes them last. + - name: Pack libraries and pointer packages + run: | + dotnet pack ProjGraph.slnx --no-build --configuration Release -p:Version="$VERSION" --output artifacts/packages + mkdir -p artifacts/pointers + mv "artifacts/packages/ProjGraph.Cli.$VERSION.nupkg" "artifacts/packages/ProjGraph.Mcp.$VERSION.nupkg" artifacts/pointers/ + + - name: Pack framework-dependent fallback packages + run: | + dotnet pack src/ProjGraph.Cli --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages + dotnet pack src/ProjGraph.Mcp --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages + + - name: Verify the portable packages + run: | + bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any + bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers + + # The fallback packages are JIT builds, so the native-build guard is filtered out; every parity + # test still runs them against the JIT build of the same commit. + - name: Smoke test the fallback packages + env: + PROJGRAPH_SMOKE_REQUIRED: 'true' + PROJGRAPH_SMOKE_CLI_NATIVE: artifacts/fallback/cli/tools/net10.0/any/ProjGraph.Cli.dll + PROJGRAPH_SMOKE_CLI_REFERENCE: src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll + PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/fallback/mcp/tools/net10.0/any/ProjGraph.Mcp.dll + PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll + run: | + unzip -q "artifacts/packages/ProjGraph.Cli.any.$VERSION.nupkg" -d artifacts/fallback/cli + unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp + dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ + --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" \ + --logger "console;verbosity=normal" + + - name: Upload libraries and fallback packages + uses: actions/upload-artifact@v7 + with: + name: packages-portable + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg + if-no-files-found: error + retention-days: 7 + + - name: Upload pointer packages + uses: actions/upload-artifact@v7 + with: + name: pointers + path: artifacts/pointers/*.nupkg + if-no-files-found: error + retention-days: 7 +``` + +```bash +docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false .github/workflows/pack.yml .github/workflows/ci.yml +``` + +Expected: no output, exit 0. + +- [ ] **Step 4: Run `pack-portable`'s commands from a clean clone** + +```bash +git add .github/workflows/pack.yml +git commit -m "ci: add the pack workflow for native, fallback, and pointer packages" +rm -rf /tmp/pg-pr3-portable && git clone --quiet --branch feat/aot-packaging . /tmp/pg-pr3-portable +cd /tmp/pg-pr3-portable +export VERSION=0.0.0-local.4 +dotnet restore ProjGraph.slnx +dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" +dotnet pack ProjGraph.slnx --no-build --configuration Release -p:Version="$VERSION" --output artifacts/packages +mkdir -p artifacts/pointers +mv "artifacts/packages/ProjGraph.Cli.$VERSION.nupkg" "artifacts/packages/ProjGraph.Mcp.$VERSION.nupkg" artifacts/pointers/ +dotnet pack src/ProjGraph.Cli --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages +dotnet pack src/ProjGraph.Mcp --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages +bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any +bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers +unzip -q "artifacts/packages/ProjGraph.Cli.any.$VERSION.nupkg" -d artifacts/fallback/cli +unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp +GITHUB_ACTIONS=true \ +PROJGRAPH_SMOKE_REQUIRED=true \ +PROJGRAPH_SMOKE_CLI_NATIVE=artifacts/fallback/cli/tools/net10.0/any/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/fallback/mcp/tools/net10.0/any/ProjGraph.Mcp.dll \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ + --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" +unset VERSION +cd - && rm -rf /tmp/pg-pr3-portable +``` + +Expected: `… holds the 8 expected packages.`, `… holds the 2 expected packages.`, and `Passed: 28, Failed: 0, Skipped: 0`. The step's commands in `pack.yml` must match these exactly. + +- [ ] **Step 5: Commit** + +Already committed in Step 4 together with the workflow. If Step 4 needed fixes, commit them now: + +```bash +git add .github/workflows/pack.yml .github/scripts/verify-packages.sh +git commit -m "ci: fix the pack workflow" +``` + +--- + +### Task 6: `publish.yml` job graph and ordered pushes + +`publish.yml` becomes `prepare` → `pack` (calls `pack.yml`) → `publish`. The `publish` job pushes the pointer packages last, and only after NuGet.org lists every tool sub-package. + +**Files:** +- Create: `.github/scripts/wait-for-nuget.sh` +- Modify: `.github/workflows/publish.yml` +- Modify: `ARCHITECTURE.md`, `CLAUDE.md` + +**Interfaces:** +- Consumes: `pack.yml`'s inputs and artifacts (Task 5), and `verify-packages.sh` (Task 5). +- Produces: `.github/scripts/wait-for-nuget.sh ...`, with `NUGET_WAIT_TIMEOUT_SECONDS` (default 1800) and `NUGET_WAIT_INTERVAL_SECONDS` (default 30). + +- [ ] **Step 1: Write the NuGet.org wait** + +Create `.github/scripts/wait-for-nuget.sh`: + +```bash +#!/usr/bin/env bash +# Waits until NuGet.org's flat container lists a version for every package ID, so a pointer package +# is pushed only once every package it points to can be installed. +# Usage: wait-for-nuget.sh ... +# NUGET_WAIT_TIMEOUT_SECONDS (default 1800) and NUGET_WAIT_INTERVAL_SECONDS (default 30) set the timing. +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +version=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') +shift +timeout=${NUGET_WAIT_TIMEOUT_SECONDS:-1800} +interval=${NUGET_WAIT_INTERVAL_SECONDS:-30} +deadline=$((SECONDS + timeout)) +pending=("$@") + +while :; do + waiting=() + for id in "${pending[@]}"; do + lower_id=$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]') + if curl --silent --fail "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" | + jq --exit-status --arg version "$version" '.versions | index($version) != null' > /dev/null; then + echo "$id $version is listed on NuGet.org." + else + waiting+=("$id") + fi + done + + if [ "${#waiting[@]}" -eq 0 ]; then + exit 0 + fi + + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Timed out after ${timeout}s waiting for $version of: ${waiting[*]}" >&2 + exit 1 + fi + + echo "Waiting ${interval}s for: ${waiting[*]}" + sleep "$interval" + pending=("${waiting[@]}") +done +``` + +```bash +chmod +x .github/scripts/wait-for-nuget.sh +git add .github/scripts/wait-for-nuget.sh +git update-index --chmod=+x .github/scripts/wait-for-nuget.sh +docker run --rm --user "$(id -u):$(id -g)" --entrypoint shellcheck -v "$PWD":/s -w /s rhysd/actionlint:latest .github/scripts/wait-for-nuget.sh +``` + +- [ ] **Step 2: Test the wait against live NuGet.org** + +It only reads NuGet.org's public flat container and publishes nothing: + +```bash +NUGET_WAIT_TIMEOUT_SECONDS=5 NUGET_WAIT_INTERVAL_SECONDS=2 bash .github/scripts/wait-for-nuget.sh 1.1.0 ProjGraph.Cli ProjGraph.Mcp; echo "1 exit=$? (want 0)" +NUGET_WAIT_TIMEOUT_SECONDS=3 NUGET_WAIT_INTERVAL_SECONDS=2 bash .github/scripts/wait-for-nuget.sh 1.1.0 ProjGraph.Cli ProjGraph.Cli.linux-x64; echo "2 exit=$? (want 1)" +NUGET_WAIT_TIMEOUT_SECONDS=3 NUGET_WAIT_INTERVAL_SECONDS=2 bash .github/scripts/wait-for-nuget.sh 9.9.9 ProjGraph.Cli; echo "3 exit=$? (want 1)" +``` + +Expected: +1. Both IDs are `listed on NuGet.org`, exit 0. +2. `ProjGraph.Cli` is listed, then `Waiting 2s for: ProjGraph.Cli.linux-x64` (twice), then `::error::Timed out after 3s …`, exit 1. +3. Times out, exit 1. + +If `ProjGraph.Cli.linux-x64` has been published by the time you run this, pick any unpublished ID for case 2. + +- [ ] **Step 3: Rewrite `publish.yml`** + +Replace the whole file with: + +```yaml +name: Publish + +on: + push: + tags: + - 'v*' + # Allow re-running a release on demand (e.g. after a transient NuGet/MCP Registry failure). + # The tag must already exist: every job below derives the version from it. Pushes skip packages + # that are already on a feed, and the NuGet.org wait passes at once for packages already listed. + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag to publish (e.g. v1.0.3)' + required: true + type: string + +permissions: + contents: write + packages: write + id-token: write # Required for MCP Registry OIDC authentication + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.get_version.outputs.tag }} + version: ${{ steps.get_version.outputs.version }} + + steps: + - name: Validate tag input + if: github.event_name == 'workflow_dispatch' + env: + TAG: ${{ github.event.inputs.tag }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::'$TAG' is not a valid release tag (expected e.g. v1.0.3 or v1.0.0-rc.1)." + exit 1 + fi + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Extract version from tag + id: get_version + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + { + echo "tag=$TAG" + echo "version=${TAG#v}" + } >> "$GITHUB_OUTPUT" + echo "Tag: $TAG / Version: ${TAG#v}" + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props + + - name: Restore dependencies + run: dotnet restore ProjGraph.slnx + + - name: Build + env: + VERSION: ${{ steps.get_version.outputs.version }} + run: dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" + + - name: Test + run: dotnet test ProjGraph.slnx --no-build --configuration Release --verbosity normal + + pack: + needs: prepare + uses: ./.github/workflows/pack.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + + publish: + needs: [prepare, pack] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Download packages + uses: actions/download-artifact@v8 + with: + pattern: packages-* + path: artifacts/packages + merge-multiple: true + + - name: Download pointer packages + uses: actions/download-artifact@v8 + with: + name: pointers + path: artifacts/pointers + + - name: Verify the release package set + run: | + bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any win-x64 linux-x64 linux-arm64 linux-musl-x64 linux-musl-arm64 osx-arm64 + bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # A pointer package names its RID-specific packages, and installing it fails until they exist, so + # every other package goes first. + - name: Publish packages to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "artifacts/packages/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + + - name: Publish packages to GitHub Packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + run: dotnet nuget push "artifacts/packages/*.nupkg" --source "https://nuget.pkg.github.com/$OWNER/index.json" --api-key "$GITHUB_TOKEN" --skip-duplicate + + - name: Wait for the tool packages on NuGet.org + run: | + bash .github/scripts/wait-for-nuget.sh "$VERSION" \ + ProjGraph.Cli.any ProjGraph.Cli.win-x64 ProjGraph.Cli.linux-x64 ProjGraph.Cli.linux-arm64 \ + ProjGraph.Cli.linux-musl-x64 ProjGraph.Cli.linux-musl-arm64 ProjGraph.Cli.osx-arm64 \ + ProjGraph.Mcp.any ProjGraph.Mcp.win-x64 ProjGraph.Mcp.linux-x64 ProjGraph.Mcp.linux-arm64 \ + ProjGraph.Mcp.linux-musl-x64 ProjGraph.Mcp.linux-musl-arm64 ProjGraph.Mcp.osx-arm64 + + - name: Publish pointer packages to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "artifacts/pointers/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + + - name: Publish pointer packages to GitHub Packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + run: dotnet nuget push "artifacts/pointers/*.nupkg" --source "https://nuget.pkg.github.com/$OWNER/index.json" --api-key "$GITHUB_TOKEN" --skip-duplicate + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + files: | + artifacts/packages/* + artifacts/pointers/* + tag_name: ${{ needs.prepare.outputs.tag }} + generate_release_notes: true + draft: false + # Treat a tag with a pre-release suffix (e.g. v1.0.0-rc.1) as a pre-release. + prerelease: ${{ contains(needs.prepare.outputs.tag, '-') }} + + - name: Update server.json version + run: | + sed -i "s/\"version\": \"[^\"]\+\"/\"version\": \"$VERSION\"/g" src/ProjGraph.Mcp/.mcp/server.json + echo "Updated server.json:" + grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json + + - name: Install mcp-publisher + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar -xzf - + + - name: Wait for NuGet.org validation + run: sleep 300 + + - name: Publish to MCP Registry + uses: nick-fields/retry@v4 + with: + timeout_minutes: 15 + max_attempts: 5 + retry_wait_seconds: 120 + command: | + ./mcp-publisher login github-oidc + ./mcp-publisher publish src/ProjGraph.Mcp/.mcp/server.json +``` + +What changed compared with the old file, so a reviewer can check it quickly: +- The `Update Directory.Build.props version` step is gone. Every build and pack gets `-p:Version` instead, because jobs don't share a workspace. +- `server.json` is still edited with `sed`, now only in `publish` (spec 3.2), and `identifier` stays `ProjGraph.Mcp`, which is now the pointer package. +- The wait lists all 14 tool sub-packages, and pointer pushes come after it. + +```bash +docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false .github/workflows/publish.yml .github/workflows/pack.yml .github/workflows/ci.yml +``` + +Expected: no output, exit 0. The old file's `SC2086` infos are gone. + +- [ ] **Step 4: Check the push order and package counts by reading** + +With `publish.yml` open, confirm: +- (a) No step before `Wait for the tool packages on NuGet.org` touches `artifacts/pointers` except the download and verify steps. +- (b) The wait's ID list has 14 entries and matches `verify-packages.sh`'s `any` and six RID sets. +- (c) The verify step's sets (`libraries any` + 6 RIDs = 16 files, `pointers` = 2) add up to the 22 packages in Global Constraints. + +Every list must use the same RID spellings. A mismatch here would only surface on a release tag. + +```bash +grep -o 'linux-musl-arm64' .github/workflows/pack.yml .github/workflows/publish.yml .github/scripts/verify-packages.sh src/ProjGraph.Cli/ProjGraph.Cli.csproj src/ProjGraph.Mcp/ProjGraph.Mcp.csproj | sort | uniq -c +``` + +Expected: `pack.yml` has 1, `publish.yml` 3 (verify line + 2 wait IDs), `verify-packages.sh` 2 (comment + case), and each csproj 1. + +- [ ] **Step 5: Update the release docs** + +In `ARCHITECTURE.md`, replace the `### Release Flow` section (the heading and its code block, up to `### MCP Registry Ownership Verification`) with: + +````markdown +### Release Flow + +```sh +Tag push (v*) + │ + ├── prepare: dotnet build -p:Version= + dotnet test + ├── pack (.github/workflows/pack.yml) + │ ├── pack-native ×6, each on a matching runner (linux-musl-* inside Alpine): + │ │ win-x64 · linux-x64 · linux-arm64 · linux-musl-x64 · linux-musl-arm64 · osx-arm64 + │ │ dotnet pack -r → dotnet tool install from the packages → Tests.Smoke.Aot + │ └── pack-portable: libraries, pointer packages, framework-dependent `any` packages → Tests.Smoke.Aot + └── publish + ├── dotnet nuget push libraries + native + any → NuGet.org, GitHub Packages + ├── wait until NuGet.org lists all 14 tool packages (30 min timeout) + ├── dotnet nuget push pointer packages → NuGet.org, GitHub Packages + ├── Create GitHub Release with every package attached + └── mcp-publisher publish (GitHub OIDC auth, no token required) + └── Submits src/ProjGraph.Mcp/.mcp/server.json to the Official MCP Registry +``` + +`pack.yml` also runs on pull requests that change the tool projects, the smoke suite, the packaging +scripts, `Directory.*.props`, or `global.json`. + +### Tool Packages + +`ProjGraph.Cli` and `ProjGraph.Mcp` are pointer packages that list one package per runtime identifier. +`dotnet tool install` and `dnx` pick the Native AOT package on win-x64, linux-x64, linux-arm64, +linux-musl-x64, linux-musl-arm64, and osx-arm64, and the framework-dependent `.any` package elsewhere. +Installing a pointer package fails until every package it lists is on the feed, which is why `publish` +pushes it last. + +If a native package misbehaves after a release, remove its RID from `ToolPackageRuntimeIdentifiers` in +both tool projects and ship a patch; that platform then falls back to `any`. Remove a `linux-` +RID together with its `linux-musl-` RID, and never a musl RID alone. The RID graph treats musl +as compatible with glibc, so musl systems would install the glibc package, which can't start there. +```` + +In `CLAUDE.md`, replace the paragraph under `### Release`: + +```markdown +Releases are triggered by pushing a `v*` tag. The publish workflow builds, packs, pushes to NuGet.org and GitHub Packages, submits to the MCP Registry via `mcp-publisher`, and creates a GitHub Release. The MCP Registry ownership comment (``) must remain at the end of `src/ProjGraph.Mcp/README.md`. +``` + +with: + +```markdown +Releases are triggered by pushing a `v*` tag. `publish.yml` builds and tests with `-p:Version` from the tag, then calls `pack.yml`. That workflow packs and smoke-tests the Native AOT tool packages on a matching runner for each RID (Alpine for `linux-musl-*`) and packs the libraries, the `any` fallbacks, and the two pointer packages. `publish` then pushes everything except the pointer packages to NuGet.org and GitHub Packages, waits until NuGet.org lists all 14 tool packages, pushes the pointer packages, creates a GitHub Release, and submits to the MCP Registry via `mcp-publisher`. See `ARCHITECTURE.md` ("Tool Packages") before changing `ToolPackageRuntimeIdentifiers`. The MCP Registry ownership comment (``) must remain at the end of `src/ProjGraph.Mcp/README.md`. +``` + +- [ ] **Step 6: Commit** + +```bash +git add .github/scripts/wait-for-nuget.sh .github/workflows/publish.yml ARCHITECTURE.md CLAUDE.md +git commit -m "ci: publish native tool packages before their pointer packages" +``` + +--- + +### Task 7: Pull request and validation on every platform + +Pushing and opening the PR are outward-facing: confirm with the user first. Nothing in this task publishes a package. `pack.yml` runs on the PR with a `0.0.0-ci.` version and only uploads workflow artifacts. + +- [ ] **Step 1: Final local gate** + +```bash +dotnet build ProjGraph.slnx -c Release +dotnet format ProjGraph.slnx --no-restore --verify-no-changes +dotnet test ProjGraph.slnx -c Release --no-build +docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false .github/workflows/ci.yml .github/workflows/pack.yml .github/workflows/publish.yml +docker run --rm --user "$(id -u):$(id -g)" --entrypoint shellcheck -v "$PWD":/s -w /s rhysd/actionlint:latest .github/scripts/native-tool-smoke.sh .github/scripts/verify-packages.sh .github/scripts/wait-for-nuget.sh +git ls-files -s .github/scripts +grep -rnE '\b(dtk|rtk) ' .github CONTRIBUTING.md CLAUDE.md ARCHITECTURE.md +git status --short +``` + +Expected: +- 0 warnings, format clean, and every suite green with `Tests.Smoke.Aot` `Skipped: 30`. +- Both linters silent. +- All three scripts show mode `100755`. +- The `grep` prints nothing. +- A clean working tree. + +- [ ] **Step 2: Push and open the PR (after user confirmation)** + +Write the description to `/tmp/pr3-description.md` (with the Write tool), then: + +```bash +git push -u origin feat/aot-packaging +gh pr create --base develop --title "feat: Native AOT tool packages and release pipeline (PR 3 of 3)" --body-file /tmp/pr3-description.md +``` + +The description must include: +- A summary: the two tool projects as RID-specific AOT packages with an `any` fallback, the shared smoke script/action, `pack.yml`, and the new `publish.yml` order. +- The 12 "Deviations from the spec" above. Deviation 1 goes first and cites the Alpine reproduction. +- The package sizes, and that the release now ships 22 packages. +- The PR 2 follow-ups that are included (guard tests, stderr capture, `CreateApp`, split steps, TRX upload), and the one that's obsolete (chmod on extracted binaries). +- The maintainer checklist from Step 5 below. +- It ends with the attribution line from the session's system reminder. + +- [ ] **Step 3: Watch the checks** + +```bash +gh pr checks --watch +``` + +Expected checks: +- The three `build` matrix jobs. +- `aot-smoke`. +- `pack-native (win-x64)`, `pack-native (linux-x64)`, `pack-native (linux-arm64)`, `pack-native (linux-musl-x64)`, `pack-native (linux-musl-arm64)`, and `pack-native (osx-arm64)`. +- `pack-portable`. + +Each `pack-native` log ends its smoke step with `Passed: 30, Failed: 0, Skipped: 0` and `… holds the 2 expected packages.`. `pack-portable` shows the `8` and `2` package counts and `Passed: 28`. + +- [ ] **Step 4: Triage runner-only failures (use superpowers:systematic-debugging; read the log and the `smoke-results-` artifact before changing anything)** + +The platforms below were never run locally. Likely causes and the fix to try, in order: + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `pack-native (win-x64)`: `NativeBuildGuardTests` fails on `artifacts/tools/Cli/projgraph.exe`, or the smoke tests can't start it | On Windows the tool-path command may be a launcher rather than the native executable | Point the native variables at the executable in the tool store: `find artifacts/tools/Cli/.store -type f -name ProjGraph.Cli.exe -path '*/tools/*'`, and the same for `ProjGraph.Mcp.exe`. Set the variables from those paths in the script's Windows branch, then add a deviation note | +| `pack-native (win-x64)`: the MCP reference apphost exits at once, "You must install .NET" | The apphost can't find the runtime | Export `DOTNET_ROOT` from the directory of `command -v dotnet` in the script's Windows branch | +| Windows output differs only in line endings or path separators | Both builds should see the same environment, so this is a real finding, not noise | Find which build differs and why; don't normalize it away without understanding it | +| `pack-native (linux-musl-arm64)`: `docker: … no matching manifest` or Docker isn't available | Arm64 hosted runner image issue | Confirm with `docker info` in a debug step. If Docker is missing there, stop and ask the user: running the job in `container:` doesn't work, because JavaScript actions don't run in Alpine containers on Arm64 | +| musl legs: `apk add` fails | Transient mirror error | Re-run the job; if it persists, pin the image tag (e.g. `10.0-alpine3.22`) | +| `pack-native (osx-arm64)`: ILC link errors | Xcode command-line tools | Read the linker message; `macos-latest` ships Xcode, so a missing SDK is unexpected | +| Any leg: `NU1101`/`not found in NuGet feeds` during `dotnet tool install` | The pointer and native packages don't have the same version | Check that the script's `-p:Version` is identical for the build, pointer pack, and native pack | + +Commit fixes with focused messages. Re-run until every check is green. + +- [ ] **Step 5: Hand the post-merge release checks to the maintainer** + +These publish public packages or change settings, so they belong to the user. Put them in the PR description as a checklist: + +1. **NuGet.org API key scope.** Confirm that the key behind `NUGET_API_KEY` can push the 14 new package IDs, i.e. its glob covers `ProjGraph.*` (spec risk table). If it lists explicit package IDs, the release fails at the first native push. +2. **Make `aot-smoke` required** on `develop`. This is still pending from PR 2; the ruleset has no required status checks yet. +3. **RC dry run** (spec Testing table). Push a pre-release tag such as `v1.2.0-rc.1` on the merge commit and watch `publish.yml`. The `publish` log must show: + - `… holds the 16 expected packages.` and `… holds the 2 expected packages.`; + - the package pushes; + - `is listed on NuGet.org.` for all 14 IDs; + - only then the pointer pushes, the GitHub Release, and the MCP Registry publish. +4. **Install checks with the RC version**, on one machine per native RID: + - `dotnet tool install -g ProjGraph.Cli --version `, then `projgraph --help`. + - `(printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"rc-check","version":"1.0"}}}'; sleep 10) | dnx ProjGraph.Mcp@ --yes`, which must print a result whose `serverInfo.version` starts with ``. + - On Linux and macOS, `ls -l ~/.dotnet/tools/projgraph` shows the link into `…projgraph.cli.…`. + - For musl: `docker run --rm mcr.microsoft.com/dotnet/sdk:10.0-alpine sh -c 'dotnet tool install -g ProjGraph.Cli --version && ~/.dotnet/tools/projgraph --help'`. +5. **Fallback check.** On an unlisted platform (e.g. an Intel Mac, `osx-x64`, or Windows on Arm, `win-arm64`), the same install must resolve `projgraph.cli.any` and run. +6. **MCP Registry.** `curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.HandyS11/projgraph"` lists the RC version. + +## Exit criteria (spec, PR 3, with deviation 1) + +- **This PR:** + - Every check above is green, including `pack-native` on all six RIDs and `pack-portable`. + - Local suites are green; actionlint and shellcheck are clean. +- **After merge (maintainer):** + - The RC dry run publishes all 22 packages in the specified order, and the release smoke passes on all six RIDs. + - `dnx`/`dotnet tool install` work on each native RID and on a fallback platform. + - The MCP Registry entry resolves. From 0836cc015f8205d726ac4b66a8437d8c8873137c Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 00:24:19 +0200 Subject: [PATCH 02/15] test: guard that smoke builds are native and capture MCP server stderr Co-Authored-By: Claude Opus 5 (1M context) --- .../Helpers/McpServerPair.cs | 23 ++++++++++-- .../McpParityTests.cs | 10 ++++- .../NativeBuildGuardTests.cs | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs diff --git a/tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs b/tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs index 23cd163..16268fd 100644 --- a/tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs +++ b/tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Client; +using System.Collections.Concurrent; namespace ProjGraph.Tests.Smoke.Aot.Helpers; @@ -9,6 +10,8 @@ namespace ProjGraph.Tests.Smoke.Aot.Helpers; /// public sealed class McpServerPair : IAsyncLifetime { + private readonly ConcurrentQueue _nativeStandardError = new(); + private readonly ConcurrentQueue _referenceStandardError = new(); private McpClient? _native; private McpClient? _reference; @@ -18,11 +21,22 @@ public sealed class McpServerPair : IAsyncLifetime /// The native and the reference client. public async Task<(McpClient Native, McpClient Reference)> GetClientsAsync() { - _native ??= await ConnectAsync(SmokeEnvironment.McpNative); - _reference ??= await ConnectAsync(SmokeEnvironment.McpReference); + _native ??= await ConnectAsync(SmokeEnvironment.McpNative, _nativeStandardError); + _reference ??= await ConnectAsync(SmokeEnvironment.McpReference, _referenceStandardError); return (_native, _reference); } + /// + /// Describes everything both servers have written to standard error so far. The servers log + /// warnings and unhandled exceptions there, which the protocol results alone never show. + /// + /// The native and the reference server's standard error, labelled. + public string DescribeStandardError() + { + return $"Native server stderr:\n{string.Join('\n', _nativeStandardError)}\n" + + $"Reference server stderr:\n{string.Join('\n', _referenceStandardError)}"; + } + /// public Task InitializeAsync() { @@ -43,14 +57,15 @@ public async Task DisposeAsync() } } - private static async Task ConnectAsync(SmokeCommand command) + private static async Task ConnectAsync(SmokeCommand command, ConcurrentQueue standardError) { var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = "ProjGraph AOT smoke", Command = command.FileName, Arguments = [.. command.LeadingArguments], - WorkingDirectory = SmokeEnvironment.RepositoryRoot + WorkingDirectory = SmokeEnvironment.RepositoryRoot, + StandardErrorLines = standardError.Enqueue }); return await McpClient.CreateAsync(transport); diff --git a/tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs b/tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs index 194f464..7091180 100644 --- a/tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs +++ b/tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs @@ -5,6 +5,7 @@ using ProjGraph.Tests.Smoke.Aot.Helpers; using System.Text.Json; using System.Text.Json.Nodes; +using Xunit.Abstractions; namespace ProjGraph.Tests.Smoke.Aot; @@ -13,7 +14,9 @@ namespace ProjGraph.Tests.Smoke.Aot; /// same requests, and requires identical protocol results. /// /// The native and reference servers shared by this class. -public sealed class McpParityTests(McpServerPair servers) : IClassFixture +/// Receives both servers' standard error, which xUnit reports with a failing test. +public sealed class McpParityTests(McpServerPair servers, ITestOutputHelper output) + : IClassFixture, IDisposable { /// /// The time limit for one MCP request. The SDK's own timeout (2.2.0) only covers initialization, @@ -22,6 +25,11 @@ public sealed class McpParityTests(McpServerPair servers) : IClassFixture private static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(2); + public void Dispose() + { + output.WriteLine(servers.DescribeStandardError()); + } + [AotSmokeFact] public async Task Initialize_ShouldAdvertiseTheSameServer() { diff --git a/tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs b/tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs new file mode 100644 index 0000000..3d70bcd --- /dev/null +++ b/tests/ProjGraph.Tests.Smoke.Aot/NativeBuildGuardTests.cs @@ -0,0 +1,37 @@ +using ProjGraph.Tests.Smoke.Aot.Helpers; + +namespace ProjGraph.Tests.Smoke.Aot; + +/// +/// Guards the suite's premise: the builds under test must be Native AOT executables. A JIT apphost +/// or a framework-dependent .dll matches the reference trivially, so a misconfigured job, or a +/// tool package that lost PublishAot, would otherwise pass every parity test. +/// +public sealed class NativeBuildGuardTests +{ + [AotSmokeFact] + public void CliNative_ShouldBeNativeAotExecutable() + { + AssertNativeAotExecutable(SmokeEnvironment.CliNative); + } + + [AotSmokeFact] + public void McpNative_ShouldBeNativeAotExecutable() + { + AssertNativeAotExecutable(SmokeEnvironment.McpNative); + } + + private static void AssertNativeAotExecutable(SmokeCommand command) + { + command.LeadingArguments.Should().BeEmpty("a Native AOT build starts directly, not through the dotnet host"); + + // dotnet tool install links the command to the executable in its package store on Linux and macOS. + var executable = new FileInfo(command.FileName); + var target = executable.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? executable.FullName; + + // A JIT apphost always ships its managed entry point beside it: .dll for or .exe. + var stem = OperatingSystem.IsWindows() ? Path.ChangeExtension(target, extension: null) : target; + File.Exists(stem + ".dll").Should().BeFalse( + $"{target} has a managed entry point beside it, so it is a JIT apphost, not a Native AOT executable"); + } +} From 505141e4d4ed10f8a04257509ce25e6490a1de09 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 00:28:45 +0200 Subject: [PATCH 03/15] refactor(cli): narrow the IL3050 suppression to CommandApp construction Co-Authored-By: Claude Opus 5 (1M context) --- src/ProjGraph.Cli/Program.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ProjGraph.Cli/Program.cs b/src/ProjGraph.Cli/Program.cs index a11c2e3..2ea1b3a 100644 --- a/src/ProjGraph.Cli/Program.cs +++ b/src/ProjGraph.Cli/Program.cs @@ -9,10 +9,6 @@ namespace ProjGraph.Cli; internal static class Program { - [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", - Justification = "Spectre.Console.Cli reflects over the command and settings types. ProjGraph.Cli and " + - "Spectre.Console.Cli are trimmer root assemblies, so that metadata is kept, and " + - "tests/ProjGraph.Tests.Smoke.Aot runs every command natively.")] public static int Main(string[] args) { const string packageDiagramCommandName = "visualize"; @@ -27,8 +23,7 @@ public static int Main(string[] args) // Register CLI-specific services services.AddSingleton(); - var registrar = new TypeRegistrar(services); - var app = new CommandApp(registrar); + var app = CreateApp(new TypeRegistrar(services)); app.Configure(config => { @@ -67,4 +62,13 @@ public static int Main(string[] args) return app.Run(args); } + + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", + Justification = "Spectre.Console.Cli reflects over the command and settings types. ProjGraph.Cli and " + + "Spectre.Console.Cli are trimmer root assemblies, so that metadata is kept, and " + + "tests/ProjGraph.Tests.Smoke.Aot runs every command natively.")] + private static CommandApp CreateApp(TypeRegistrar registrar) + { + return new CommandApp(registrar); + } } From 8ef99b3cfa54955011cb655119b6df3abeb55fb2 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 00:38:21 +0200 Subject: [PATCH 04/15] feat: pack the CLI and MCP server as RID-specific Native AOT tools Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 ++ docfx/guides/getting-started.md | 5 +++++ src/ProjGraph.Cli/ProjGraph.Cli.csproj | 18 +++++++++++++--- src/ProjGraph.Cli/README.md | 4 ++++ src/ProjGraph.Mcp/ProjGraph.Mcp.csproj | 21 ++++++++++++------- src/ProjGraph.Mcp/README.md | 4 ++++ .../Helpers/McpServerProcess.cs | 20 +++++------------- 8 files changed, 49 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29afe5b..ddb77fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,5 +96,5 @@ jobs: PROJGRAPH_SMOKE_CLI_NATIVE: artifacts/native/cli/ProjGraph.Cli PROJGRAPH_SMOKE_CLI_REFERENCE: src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/native/mcp/ProjGraph.Mcp - PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll + PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll run: dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release --logger "console;verbosity=normal" diff --git a/CLAUDE.md b/CLAUDE.md index 3e74f73..5ea5490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,8 @@ Application/ **MCP stdout safety** — The MCP server overrides `IOutputConsole` with `NullOutputConsole` to prevent ANSI markup from leaking onto the JSON-RPC stdio transport. +**Tool packaging** — `ProjGraph.Cli` and `ProjGraph.Mcp` set `PublishAot=true` and `ToolPackageRuntimeIdentifiers`, so a plain `dotnet pack` builds only the pointer package. Each native package needs `dotnet pack -r ` on a matching OS (Alpine for `linux-musl-*`), and the `any` fallback needs `dotnet pack -r any -p:PublishAot=false`. Because `PublishAot` is set, plain JIT builds also get AOT feature switches (e.g. `IsDynamicCodeSupported=false`) in their `runtimeconfig.json`. `.github/workflows/pack.yml` builds every package. + **New feature checklist:** 1. Add domain models to `ProjGraph.Core` if needed. diff --git a/docfx/guides/getting-started.md b/docfx/guides/getting-started.md index bd57bf9..44db29d 100644 --- a/docfx/guides/getting-started.md +++ b/docfx/guides/getting-started.md @@ -18,6 +18,11 @@ Check what you have: dotnet --version ``` +On Windows x64, Linux x64 and Arm64 (glibc or musl), and macOS Arm64, both the +CLI and the MCP server install as Native AOT executables that start without +loading the .NET runtime. Other platforms get a framework-dependent build that +runs on the runtime the SDK includes. + ProjGraph reads source files directly. You do not need to build the project you are analysing, run a database, or apply migrations first. diff --git a/src/ProjGraph.Cli/ProjGraph.Cli.csproj b/src/ProjGraph.Cli/ProjGraph.Cli.csproj index f1d99da..843a015 100644 --- a/src/ProjGraph.Cli/ProjGraph.Cli.csproj +++ b/src/ProjGraph.Cli/ProjGraph.Cli.csproj @@ -2,11 +2,23 @@ Exe - + LatestMajor - + + true + win-x64;linux-x64;linux-arm64;linux-musl-x64;linux-musl-arm64;osx-arm64;any + + false + + false + + true true false diff --git a/src/ProjGraph.Cli/README.md b/src/ProjGraph.Cli/README.md index 5c828ab..d6a65e1 100644 --- a/src/ProjGraph.Cli/README.md +++ b/src/ProjGraph.Cli/README.md @@ -12,6 +12,10 @@ class hierarchies, and computing key solution metrics. dotnet tool install -g ProjGraph.Cli ``` +Installing needs the [.NET 10 SDK](https://dotnet.microsoft.com/download) or later. On Windows x64, Linux x64 and +Arm64 (glibc or musl), and macOS Arm64, the tool is a Native AOT executable that starts without loading the .NET +runtime. Other platforms get a framework-dependent build that runs on the .NET 10 runtime. + ## Commands ### `visualize` - Project Dependencies diff --git a/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj b/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj index c8437ce..8a87915 100644 --- a/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj +++ b/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj @@ -8,15 +8,20 @@ true McpServer - - true - true + + true + win-x64;linux-x64;linux-arm64;linux-musl-x64;linux-musl-arm64;osx-arm64;any + + false + + false - - true - - + true true false diff --git a/src/ProjGraph.Mcp/README.md b/src/ProjGraph.Mcp/README.md index 04c1a76..fb44bef 100644 --- a/src/ProjGraph.Mcp/README.md +++ b/src/ProjGraph.Mcp/README.md @@ -25,6 +25,10 @@ MCP settings file: } ``` +`dnx` ships with the [.NET 10 SDK](https://dotnet.microsoft.com/download). On Windows x64, Linux x64 and Arm64 (glibc +or musl), and macOS Arm64, it runs a Native AOT build of the server that starts without loading the .NET runtime. +Other platforms get a framework-dependent build that runs on the .NET 10 runtime. + Or run from source: ```json diff --git a/tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs b/tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs index 785afba..efb0cc9 100644 --- a/tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs +++ b/tests/ProjGraph.Tests.Integration.Mcp/Helpers/McpServerProcess.cs @@ -11,9 +11,9 @@ internal static class McpServerProcess { /// /// Connects a client to the server apphost in ProjGraph.Mcp's own build output (guaranteed - /// up to date by the ProjectReference). ProjGraph.Mcp is a self-contained exe, so its build - /// lands in a RID subdirectory and must be launched via its apphost — the DLL that the - /// ProjectReference copies into the test output has no runtime next to it and cannot start. + /// up to date by the ProjectReference). ProjGraph.Mcp builds framework-dependent, so the apphost + /// sits directly in bin/{Configuration}/{tfm}/; RID subdirectories hold + /// dotnet pack -r or dotnet publish -r output and are ignored. /// The client deliberately advertises no capabilities — in particular no workspace roots. /// /// A connected client; disposing it stops the server process. @@ -34,20 +34,10 @@ private static string LocateServerExecutable() var testOutput = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); var binRoot = TestPathHelper.GetRootPath(Path.Combine( "src", "ProjGraph.Mcp", "bin", testOutput.Parent!.Name, testOutput.Name)); - Directory.Exists(binRoot).Should().BeTrue( - $"the MCP server build output must exist at {binRoot}"); var exeName = OperatingSystem.IsWindows() ? "ProjGraph.Mcp.exe" : "ProjGraph.Mcp"; + var serverExe = new FileInfo(Path.Combine(binRoot, exeName)); - // The build RID matches the machine that built it, so probing the RID subdirectories - // is exact enough without reconstructing the RID by hand. Preferring the most recently - // written apphost keeps a dev machine with stale cross-RID leftovers deterministic. - var serverExe = Directory.GetDirectories(binRoot) - .Select(ridDir => new FileInfo(Path.Combine(ridDir, exeName))) - .Where(apphost => apphost.Exists) - .OrderByDescending(apphost => apphost.LastWriteTimeUtc) - .FirstOrDefault(); - - serverExe.Should().NotBeNull($"the MCP server apphost must be present under {binRoot}"); + serverExe.Exists.Should().BeTrue($"the MCP server apphost must be present at {serverExe.FullName}"); return serverExe.FullName; } } From 36afe44265df3f1d3779d04017e078c080b85a83 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 00:47:57 +0200 Subject: [PATCH 05/15] ci: smoke-test native tools through their packages Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/native-tool-smoke/action.yml | 67 ++++++++++++++++++++ .github/scripts/native-tool-smoke.sh | 65 +++++++++++++++++++ .github/workflows/ci.yml | 38 ++--------- 3 files changed, 137 insertions(+), 33 deletions(-) create mode 100644 .github/actions/native-tool-smoke/action.yml create mode 100755 .github/scripts/native-tool-smoke.sh diff --git a/.github/actions/native-tool-smoke/action.yml b/.github/actions/native-tool-smoke/action.yml new file mode 100644 index 0000000..59694b5 --- /dev/null +++ b/.github/actions/native-tool-smoke/action.yml @@ -0,0 +1,67 @@ +name: Native tool smoke test +description: >- + Runs .github/scripts/native-tool-smoke.sh for one runtime identifier: builds the solution, packs the + Native AOT ProjGraph.Cli and ProjGraph.Mcp tool packages, installs both tools from them, and runs + tests/ProjGraph.Tests.Smoke.Aot against the JIT build of the same commit. linux-musl-* RIDs run + inside an Alpine .NET SDK container. Expects the repository to be checked out. + +inputs: + rid: + description: Runtime identifier to pack. The runner must match its OS and architecture (Linux for linux-musl-*). + required: true + version: + description: Package version, passed as -p:Version to every build and pack. + required: true + +runs: + using: composite + steps: + - name: Setup .NET + if: ${{ !startsWith(inputs.rid, 'linux-musl-') }} + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props + + - name: Install Native AOT prerequisites + if: ${{ runner.os == 'Linux' && !startsWith(inputs.rid, 'linux-musl-') }} + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + - name: Pack and smoke test + if: ${{ !startsWith(inputs.rid, 'linux-musl-') }} + shell: bash + env: + RID: ${{ inputs.rid }} + VERSION: ${{ inputs.version }} + run: bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION" + + # musl binaries must be linked on a musl system. The container runs as root, so the files it + # writes are handed back to the runner user even when the script fails. + - name: Pack and smoke test in Alpine + if: ${{ startsWith(inputs.rid, 'linux-musl-') }} + shell: bash + env: + RID: ${{ inputs.rid }} + VERSION: ${{ inputs.version }} + run: | + docker run --rm --volume "$PWD:/src" --workdir /src \ + --env RID --env VERSION --env GITHUB_ACTIONS --env DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + mcr.microsoft.com/dotnet/sdk:10.0-alpine \ + sh -c 'apk add --no-cache bash clang build-base zlib-dev && + status=0 && + { bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION" || status=$?; } && + chown -R "$(stat -c %u:%g /src)" /src && + exit "$status"' + + - name: Upload smoke test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-results-${{ inputs.rid }} + path: artifacts/smoke-results + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/scripts/native-tool-smoke.sh b/.github/scripts/native-tool-smoke.sh new file mode 100755 index 0000000..5ec9c49 --- /dev/null +++ b/.github/scripts/native-tool-smoke.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Builds the solution, packs the Native AOT ProjGraph.Cli and ProjGraph.Mcp tool packages for one +# runtime identifier, installs both tools from those packages the way users do, and runs +# tests/ProjGraph.Tests.Smoke.Aot against the JIT build of the same commit. +# Usage: native-tool-smoke.sh +# Run it from the repository root, on an OS that matches the RID (Alpine for linux-musl-*). +# Leaves the RID-specific packages in artifacts/packages, the pointer packages in artifacts/pointers, +# and the test results in artifacts/smoke-results. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +rid=$1 +version=$2 + +dotnet restore ProjGraph.slnx +dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$version" + +# The pointer packages are only needed to install the tools below; a release publishes the ones +# from the pack-portable job. +dotnet pack src/ProjGraph.Cli --no-build --configuration Release -p:Version="$version" --output artifacts/pointers +dotnet pack src/ProjGraph.Mcp --no-build --configuration Release -p:Version="$version" --output artifacts/pointers + +# One native pack at a time: each ILC run takes several GB of memory. +dotnet pack src/ProjGraph.Cli --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages +dotnet pack src/ProjGraph.Mcp --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages + +# Only the local packages are sources, so nothing resolves from NuGet.org, and a fresh package folder +# keeps a cached copy of the same version from being installed instead. +cat > artifacts/smoke-nuget.config <<'XML' + + + + + + + + +XML +rm -rf artifacts/tools artifacts/tool-install-packages +for tool in Cli Mcp; do + NUGET_PACKAGES="$PWD/artifacts/tool-install-packages" dotnet tool install "ProjGraph.$tool" --version "$version" \ + --configfile artifacts/smoke-nuget.config --tool-path "artifacts/tools/$tool" +done + +exe='' +mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll +if [ "${RUNNER_OS:-}" = 'Windows' ]; then + exe='.exe' + # The MCP SDK starts stdio servers through `cmd.exe /c` on Windows, which is fragile with a quoted + # dotnet host path. The JIT apphost runs the same build without one. + mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.exe +fi + +PROJGRAPH_SMOKE_REQUIRED=true \ +PROJGRAPH_SMOKE_CLI_NATIVE="artifacts/tools/Cli/projgraph$exe" \ +PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ +PROJGRAPH_SMOKE_MCP_NATIVE="artifacts/tools/Mcp/ProjGraph.Mcp$exe" \ +PROJGRAPH_SMOKE_MCP_REFERENCE="$mcp_reference" \ +dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ + --logger "console;verbosity=normal" --logger "trx;LogFileName=smoke.trx" \ + --results-directory artifacts/smoke-results diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddb77fc..19cb8e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,37 +64,9 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Setup .NET - uses: actions/setup-dotnet@v6 + # The same pack → install → smoke path that pack.yml runs for every release platform. + - name: Pack and smoke test the native tools + uses: ./.github/actions/native-tool-smoke with: - global-json-file: global.json - cache: true - cache-dependency-path: Directory.Packages.props - - - name: Install Native AOT prerequisites - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends clang zlib1g-dev - - - name: Restore dependencies - run: dotnet restore ProjGraph.slnx - - - name: Build - run: dotnet build ProjGraph.slnx --no-restore --configuration Release - - # --artifacts-path keeps the native publish out of src/*/bin and obj. ProjGraph.Mcp is - # self-contained, so an in-place publish would overwrite the JIT reference build's apphost - # and runtimeconfig.json in bin/Release/net10.0/linux-x64. - - name: Publish native executables - run: | - dotnet publish src/ProjGraph.Cli --configuration Release --runtime linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build --output artifacts/native/cli - dotnet publish src/ProjGraph.Mcp --configuration Release --runtime linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build --output artifacts/native/mcp - - - name: Smoke test native executables - env: - PROJGRAPH_SMOKE_REQUIRED: 'true' - PROJGRAPH_SMOKE_CLI_NATIVE: artifacts/native/cli/ProjGraph.Cli - PROJGRAPH_SMOKE_CLI_REFERENCE: src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll - PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/native/mcp/ProjGraph.Mcp - PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll - run: dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release --logger "console;verbosity=normal" + rid: linux-x64 + version: 0.0.0-ci.${{ github.run_number }} From 346dd5b4e7240017a6518f4e06d7ebc50b7b8ad6 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 01:12:18 +0200 Subject: [PATCH 06/15] docs: describe the package-based Native AOT smoke check Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- CONTRIBUTING.md | 40 +++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ea5490..5eda88e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ Application/ | `Tests.Integration.Cli` | CLI end-to-end tests | | `Tests.Integration.Mcp` | MCP tool integration tests | | `Tests.Contract` | MCP contract validation & DI wiring | -| `Tests.Smoke.Aot` | Native AOT vs JIT parity for the CLI and MCP executables (skipped unless `PROJGRAPH_SMOKE_*` is set; runs in the `aot-smoke` CI job) | +| `Tests.Smoke.Aot` | Native AOT vs JIT parity for the installed CLI and MCP tools (skipped unless `PROJGRAPH_SMOKE_*` is set; `.github/scripts/native-tool-smoke.sh` runs it in the `aot-smoke` CI job and for every RID in `pack.yml`) | | `Tests.Shared` | Shared helpers (`TestDirectory`, `TestPathHelper`) | ### Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0f0d50..9d411a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,31 +58,41 @@ dotnet test tests/ProjGraph.Tests.Unit.ClassDiagram --filter "ClassAnalysisDepth ### Test Organisation -| Project | Purpose | -|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `Tests.Unit.*` | Unit tests per library | -| `Tests.Integration.Cli` | CLI end-to-end tests | -| `Tests.Integration.Mcp` | MCP tool integration tests | -| `Tests.Contract` | MCP contract validation & DI wiring | -| `Tests.Smoke.Aot` | Native AOT vs JIT parity for the CLI and MCP executables (skipped unless `PROJGRAPH_SMOKE_*` is set; runs in the `aot-smoke` CI job) | -| `Tests.Shared` | Shared helpers (`TestDirectory`, `TestPathHelper`) | +| Project | Purpose | +|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Tests.Unit.*` | Unit tests per library | +| `Tests.Integration.Cli` | CLI end-to-end tests | +| `Tests.Integration.Mcp` | MCP tool integration tests | +| `Tests.Contract` | MCP contract validation & DI wiring | +| `Tests.Smoke.Aot` | Native AOT vs JIT parity for the CLI and MCP tools (skipped unless `PROJGRAPH_SMOKE_*` is set; CI runs it through `.github/actions/native-tool-smoke`) | +| `Tests.Shared` | Shared helpers (`TestDirectory`, `TestPathHelper`) | ### Native AOT smoke tests `Tests.Smoke.Aot` compares Native AOT builds of the CLI and MCP server with the JIT build of the -same commit. It is skipped unless the `PROJGRAPH_SMOKE_*` variables are set, and the `aot-smoke` -CI job runs it on every PR. Native AOT on Linux needs `clang` (or `gcc`) and `zlib1g-dev`, as the -CI job installs. The commands below are for linux-x64; the `--artifacts-path` keeps the -self-contained MCP publish from overwriting the JIT build: +same commit. It is skipped unless the `PROJGRAPH_SMOKE_*` variables are set. CI runs +`.github/scripts/native-tool-smoke.sh` through the `.github/actions/native-tool-smoke` action: it +packs the native tool packages, installs them with `dotnet tool install`, and tests the installed +tools. The `aot-smoke` job does this for linux-x64 on every PR, and `pack.yml` does it for every +release platform. Native AOT on Linux needs `clang` (or `gcc`) and `zlib1g-dev`. + +To run the same check locally on Linux: + +```bash +bash .github/scripts/native-tool-smoke.sh linux-x64 0.0.0-local.1 +``` + +For a quicker loop, test published binaries instead (run one publish at a time; each takes several GB +of memory): ```bash dotnet build ProjGraph.slnx -c Release -dotnet publish src/ProjGraph.Cli -c Release -r linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build -o artifacts/native/cli -dotnet publish src/ProjGraph.Mcp -c Release -r linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build -o artifacts/native/mcp +dotnet publish src/ProjGraph.Cli -c Release -r linux-x64 -o artifacts/native/cli +dotnet publish src/ProjGraph.Mcp -c Release -r linux-x64 -o artifacts/native/mcp PROJGRAPH_SMOKE_CLI_NATIVE=artifacts/native/cli/ProjGraph.Cli \ PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/native/mcp/ProjGraph.Mcp \ -PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll \ +PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll \ dotnet test tests/ProjGraph.Tests.Smoke.Aot -c Release --no-build ``` From c992bdfd194a87b8014e09cf461a22c54308220e Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 01:20:51 +0200 Subject: [PATCH 07/15] ci: add the pack workflow for native, fallback, and pointer packages Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/verify-packages.sh | 56 ++++++++++ .github/workflows/pack.yml | 160 +++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100755 .github/scripts/verify-packages.sh create mode 100644 .github/workflows/pack.yml diff --git a/.github/scripts/verify-packages.sh b/.github/scripts/verify-packages.sh new file mode 100755 index 0000000..adc385e --- /dev/null +++ b/.github/scripts/verify-packages.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Fails unless a directory holds exactly the .nupkg files of the named package sets. +# Usage: verify-packages.sh ... +# Sets: libraries, pointers, any, win-x64, linux-x64, linux-arm64, linux-musl-x64, linux-musl-arm64, osx-arm64. +# Keep it Bash 3.2 compatible: the osx-arm64 pack job runs it on macOS. +set -euo pipefail + +if [ "$#" -lt 3 ]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +directory=$1 +version=$2 +shift 2 + +expected="" +add() { + expected="$expected$1.$version.nupkg"$'\n' +} + +for set in "$@"; do + case "$set" in + libraries) + for id in ProjGraph.Core ProjGraph.Lib ProjGraph.Lib.Core ProjGraph.Lib.Dependencies \ + ProjGraph.Lib.ClassDiagram ProjGraph.Lib.EntityFramework; do + add "$id" + done + ;; + pointers) + add ProjGraph.Cli + add ProjGraph.Mcp + ;; + any | win-x64 | linux-x64 | linux-arm64 | linux-musl-x64 | linux-musl-arm64 | osx-arm64) + add "ProjGraph.Cli.$set" + add "ProjGraph.Mcp.$set" + ;; + *) + echo "::error::Unknown package set '$set'." >&2 + exit 2 + ;; + esac +done + +expected=$(printf '%s' "$expected" | sort) +actual=$(for package in "$directory"/*.nupkg; do + if [ -e "$package" ]; then basename "$package"; fi +done | sort) + +if [ "$actual" != "$expected" ]; then + echo "::error::$directory doesn't hold exactly the packages for: $*" >&2 + diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true + exit 1 +fi + +echo "$directory holds the $(printf '%s\n' "$actual" | wc -l | tr -d ' ') expected packages." diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml new file mode 100644 index 0000000..bb6ea0d --- /dev/null +++ b/.github/workflows/pack.yml @@ -0,0 +1,160 @@ +name: Pack + +# Builds every package a release ships: two Native AOT tool packages per runtime identifier, each +# smoke-tested on a matching runner, plus the libraries, the framework-dependent fallback packages, +# and the pointer packages. publish.yml calls it for a tag. It also runs on pull requests that +# change packaging, so every native build is proven before a release depends on it. +on: + workflow_call: + inputs: + ref: + description: Git ref to check out (the release tag). + required: true + type: string + version: + description: Package version. + required: true + type: string + pull_request: + paths: + - .github/workflows/pack.yml + - .github/actions/native-tool-smoke/** + - .github/scripts/native-tool-smoke.sh + - .github/scripts/verify-packages.sh + - src/ProjGraph.Cli/ProjGraph.Cli.csproj + - src/ProjGraph.Mcp/ProjGraph.Mcp.csproj + - tests/ProjGraph.Tests.Smoke.Aot/** + - Directory.Build.props + - Directory.Packages.props + - global.json + +permissions: + contents: read + +concurrency: + group: pack-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Pull request runs have no version input; any version works as long as every command uses it. + VERSION: ${{ inputs.version || format('0.0.0-ci.{0}', github.run_number) }} + +jobs: + pack-native: + name: pack-native (${{ matrix.rid }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + runner: windows-latest + - rid: linux-x64 + runner: ubuntu-latest + - rid: linux-arm64 + runner: ubuntu-24.04-arm + - rid: linux-musl-x64 + runner: ubuntu-latest + - rid: linux-musl-arm64 + runner: ubuntu-24.04-arm + - rid: osx-arm64 + runner: macos-latest + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Pack and smoke test the native tools + uses: ./.github/actions/native-tool-smoke + with: + rid: ${{ matrix.rid }} + version: ${{ env.VERSION }} + + - name: Verify the native packages + shell: bash + env: + RID: ${{ matrix.rid }} + run: bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" "$RID" + + - name: Upload native packages + uses: actions/upload-artifact@v7 + with: + name: packages-${{ matrix.rid }} + path: artifacts/packages/*.nupkg + if-no-files-found: error + retention-days: 7 + + pack-portable: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: Directory.Packages.props + + - name: Build + run: | + dotnet restore ProjGraph.slnx + dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" + + # With PublishAot set, packing the solution produces the RID-agnostic pointer packages for the + # two tools; they move to their own folder because publish.yml pushes them last. + - name: Pack libraries and pointer packages + run: | + dotnet pack ProjGraph.slnx --no-build --configuration Release -p:Version="$VERSION" --output artifacts/packages + mkdir -p artifacts/pointers + mv "artifacts/packages/ProjGraph.Cli.$VERSION.nupkg" "artifacts/packages/ProjGraph.Mcp.$VERSION.nupkg" artifacts/pointers/ + + - name: Pack framework-dependent fallback packages + run: | + dotnet pack src/ProjGraph.Cli --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages + dotnet pack src/ProjGraph.Mcp --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages + + - name: Verify the portable packages + run: | + bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any + bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers + + # The fallback packages are JIT builds, so the native-build guard is filtered out; every parity + # test still runs them against the JIT build of the same commit. + - name: Smoke test the fallback packages + env: + PROJGRAPH_SMOKE_REQUIRED: 'true' + PROJGRAPH_SMOKE_CLI_NATIVE: artifacts/fallback/cli/tools/net10.0/any/ProjGraph.Cli.dll + PROJGRAPH_SMOKE_CLI_REFERENCE: src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll + PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/fallback/mcp/tools/net10.0/any/ProjGraph.Mcp.dll + PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll + run: | + unzip -q "artifacts/packages/ProjGraph.Cli.any.$VERSION.nupkg" -d artifacts/fallback/cli + unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp + dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ + --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" \ + --logger "console;verbosity=normal" + + - name: Upload libraries and fallback packages + uses: actions/upload-artifact@v7 + with: + name: packages-portable + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg + if-no-files-found: error + retention-days: 7 + + - name: Upload pointer packages + uses: actions/upload-artifact@v7 + with: + name: pointers + path: artifacts/pointers/*.nupkg + if-no-files-found: error + retention-days: 7 From f1f60f0820818051678ba1f3f68c189534a0c3aa Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 01:26:27 +0200 Subject: [PATCH 08/15] fix(ci): create the fallback extraction folder before unzipping Classic Info-Zip unzip (shipped by ubuntu-latest) can only auto-create one missing directory level under -d. artifacts/fallback/{cli,mcp} needs two (fallback and cli/mcp), so both unzip calls in the "Smoke test the fallback packages" step failed with "checkdir: cannot create extraction directory", leaving all 28 smoke tests failing with FileNotFoundException instead of passing. Create artifacts/fallback first so unzip only has to create the one remaining level. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pack.yml | 1 + .../plans/2026-09-14-native-aot-pr3-packaging-release.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index bb6ea0d..0fa74fc 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -135,6 +135,7 @@ jobs: PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/fallback/mcp/tools/net10.0/any/ProjGraph.Mcp.dll PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll run: | + mkdir -p artifacts/fallback unzip -q "artifacts/packages/ProjGraph.Cli.any.$VERSION.nupkg" -d artifacts/fallback/cli unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ diff --git a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md index b74c1c6..71e5b35 100644 --- a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md +++ b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md @@ -1406,6 +1406,7 @@ jobs: PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/fallback/mcp/tools/net10.0/any/ProjGraph.Mcp.dll PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll run: | + mkdir -p artifacts/fallback unzip -q "artifacts/packages/ProjGraph.Cli.any.$VERSION.nupkg" -d artifacts/fallback/cli unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ @@ -1454,6 +1455,7 @@ dotnet pack src/ProjGraph.Cli --configuration Release --runtime any -p:PublishAo dotnet pack src/ProjGraph.Mcp --configuration Release --runtime any -p:PublishAot=false -p:Version="$VERSION" --output artifacts/packages bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers +mkdir -p artifacts/fallback unzip -q "artifacts/packages/ProjGraph.Cli.any.$VERSION.nupkg" -d artifacts/fallback/cli unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp GITHUB_ACTIONS=true \ From 5edf1a9ca773e4b2ce6655457fa99927a4db15e2 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 01:36:45 +0200 Subject: [PATCH 09/15] ci: publish native tool packages before their pointer packages Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/wait-for-nuget.sh | 44 +++++++++++ .github/workflows/publish.yml | 120 +++++++++++++++++++++++------- ARCHITECTURE.md | 38 +++++++--- CLAUDE.md | 2 +- 4 files changed, 168 insertions(+), 36 deletions(-) create mode 100755 .github/scripts/wait-for-nuget.sh diff --git a/.github/scripts/wait-for-nuget.sh b/.github/scripts/wait-for-nuget.sh new file mode 100755 index 0000000..d2d04ca --- /dev/null +++ b/.github/scripts/wait-for-nuget.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Waits until NuGet.org's flat container lists a version for every package ID, so a pointer package +# is pushed only once every package it points to can be installed. +# Usage: wait-for-nuget.sh ... +# NUGET_WAIT_TIMEOUT_SECONDS (default 1800) and NUGET_WAIT_INTERVAL_SECONDS (default 30) set the timing. +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +version=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') +shift +timeout=${NUGET_WAIT_TIMEOUT_SECONDS:-1800} +interval=${NUGET_WAIT_INTERVAL_SECONDS:-30} +deadline=$((SECONDS + timeout)) +pending=("$@") + +while :; do + waiting=() + for id in "${pending[@]}"; do + lower_id=$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]') + if curl --silent --fail "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" | + jq --exit-status --arg version "$version" '.versions | index($version) != null' > /dev/null; then + echo "$id $version is listed on NuGet.org." + else + waiting+=("$id") + fi + done + + if [ "${#waiting[@]}" -eq 0 ]; then + exit 0 + fi + + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Timed out after ${timeout}s waiting for $version of: ${waiting[*]}" >&2 + exit 1 + fi + + echo "Waiting ${interval}s for: ${waiting[*]}" + sleep "$interval" + pending=("${waiting[@]}") +done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a8ed403..f2fc2ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,7 +5,8 @@ on: tags: - 'v*' # Allow re-running a release on demand (e.g. after a transient NuGet/MCP Registry failure). - # The tag must already exist: every step below derives the version from it. + # The tag must already exist: every job below derives the version from it. Pushes skip packages + # that are already on a feed, and the NuGet.org wait passes at once for packages already listed. workflow_dispatch: inputs: tag: @@ -19,8 +20,12 @@ permissions: id-token: write # Required for MCP Registry OIDC authentication jobs: - publish: + prepare: runs-on: ubuntu-latest + outputs: + tag: ${{ steps.get_version.outputs.tag }} + version: ${{ steps.get_version.outputs.version }} + steps: - name: Validate tag input if: github.event_name == 'workflow_dispatch' @@ -42,22 +47,12 @@ jobs: env: TAG: ${{ github.event.inputs.tag || github.ref_name }} run: | - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "version=${TAG#v}" >> $GITHUB_OUTPUT + { + echo "tag=$TAG" + echo "version=${TAG#v}" + } >> "$GITHUB_OUTPUT" echo "Tag: $TAG / Version: ${TAG#v}" - - name: Update Directory.Build.props version - run: | - sed -i "s/.*<\/Version>/${{ steps.get_version.outputs.version }}<\/Version>/" Directory.Build.props - echo "Updated Directory.Build.props:" - grep "" Directory.Build.props - - - name: Update server.json version - run: | - sed -i "s/\"version\": \"[^\"]\+\"/\"version\": \"${{ steps.get_version.outputs.version }}\"/g" src/ProjGraph.Mcp/.mcp/server.json - echo "Updated server.json:" - grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json - - name: Setup .NET uses: actions/setup-dotnet@v6 with: @@ -69,30 +64,103 @@ jobs: run: dotnet restore ProjGraph.slnx - name: Build - run: dotnet build ProjGraph.slnx --no-restore --configuration Release + env: + VERSION: ${{ steps.get_version.outputs.version }} + run: dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" - name: Test run: dotnet test ProjGraph.slnx --no-build --configuration Release --verbosity normal - - name: Pack - run: dotnet pack ProjGraph.slnx --configuration Release --output ./artifacts + pack: + needs: prepare + uses: ./.github/workflows/pack.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + + publish: + needs: [prepare, pack] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Download packages + uses: actions/download-artifact@v8 + with: + pattern: packages-* + path: artifacts/packages + merge-multiple: true + + - name: Download pointer packages + uses: actions/download-artifact@v8 + with: + name: pointers + path: artifacts/pointers + + - name: Verify the release package set + run: | + bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any win-x64 linux-x64 linux-arm64 linux-musl-x64 linux-musl-arm64 osx-arm64 + bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # A pointer package names its RID-specific packages, and installing it fails until they exist, so + # every other package goes first. + - name: Publish packages to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "artifacts/packages/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate - - name: Publish to NuGet.org - run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate + - name: Publish packages to GitHub Packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + run: dotnet nuget push "artifacts/packages/*.nupkg" --source "https://nuget.pkg.github.com/$OWNER/index.json" --api-key "$GITHUB_TOKEN" --skip-duplicate - - name: Publish to GitHub Packages + - name: Wait for the tool packages on NuGet.org run: | - dotnet nuget push ./artifacts/*.nupkg --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + bash .github/scripts/wait-for-nuget.sh "$VERSION" \ + ProjGraph.Cli.any ProjGraph.Cli.win-x64 ProjGraph.Cli.linux-x64 ProjGraph.Cli.linux-arm64 \ + ProjGraph.Cli.linux-musl-x64 ProjGraph.Cli.linux-musl-arm64 ProjGraph.Cli.osx-arm64 \ + ProjGraph.Mcp.any ProjGraph.Mcp.win-x64 ProjGraph.Mcp.linux-x64 ProjGraph.Mcp.linux-arm64 \ + ProjGraph.Mcp.linux-musl-x64 ProjGraph.Mcp.linux-musl-arm64 ProjGraph.Mcp.osx-arm64 + + - name: Publish pointer packages to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "artifacts/pointers/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + + - name: Publish pointer packages to GitHub Packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + run: dotnet nuget push "artifacts/pointers/*.nupkg" --source "https://nuget.pkg.github.com/$OWNER/index.json" --api-key "$GITHUB_TOKEN" --skip-duplicate - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: - files: artifacts/* - tag_name: ${{ steps.get_version.outputs.tag }} + files: | + artifacts/packages/* + artifacts/pointers/* + tag_name: ${{ needs.prepare.outputs.tag }} generate_release_notes: true draft: false # Treat a tag with a pre-release suffix (e.g. v1.0.0-rc.1) as a pre-release. - prerelease: ${{ contains(steps.get_version.outputs.tag, '-') }} + prerelease: ${{ contains(needs.prepare.outputs.tag, '-') }} + + - name: Update server.json version + run: | + sed -i "s/\"version\": \"[^\"]\+\"/\"version\": \"$VERSION\"/g" src/ProjGraph.Mcp/.mcp/server.json + echo "Updated server.json:" + grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json - name: Install mcp-publisher run: | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 667bd3b..a3329ee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -115,17 +115,37 @@ Releases are triggered by pushing a `v*` Git tag and are fully automated via `.g ```sh Tag push (v*) │ - ├── Update version in Directory.Build.props & server.json - ├── dotnet build + test - ├── dotnet pack → ./artifacts/*.nupkg - ├── dotnet nuget push → NuGet.org (requires NUGET_API_KEY secret) - ├── dotnet nuget push → GitHub Packages (uses GITHUB_TOKEN) - ├── mcp-publisher publish (GitHub OIDC auth, no token required) - │ └── Submits src/ProjGraph.Mcp/.mcp/server.json to the Official MCP Registry - │ Retried up to 3× via nick-fields/retry@v3 - └── Create GitHub Release with release notes + ├── prepare: dotnet build -p:Version= + dotnet test + ├── pack (.github/workflows/pack.yml) + │ ├── pack-native ×6, each on a matching runner (linux-musl-* inside Alpine): + │ │ win-x64 · linux-x64 · linux-arm64 · linux-musl-x64 · linux-musl-arm64 · osx-arm64 + │ │ dotnet pack -r → dotnet tool install from the packages → Tests.Smoke.Aot + │ └── pack-portable: libraries, pointer packages, framework-dependent `any` packages → Tests.Smoke.Aot + └── publish + ├── dotnet nuget push libraries + native + any → NuGet.org, GitHub Packages + ├── wait until NuGet.org lists all 14 tool packages (30 min timeout) + ├── dotnet nuget push pointer packages → NuGet.org, GitHub Packages + ├── Create GitHub Release with every package attached + └── mcp-publisher publish (GitHub OIDC auth, no token required) + └── Submits src/ProjGraph.Mcp/.mcp/server.json to the Official MCP Registry ``` +`pack.yml` also runs on pull requests that change the tool projects, the smoke suite, the packaging +scripts, `Directory.*.props`, or `global.json`. + +### Tool Packages + +`ProjGraph.Cli` and `ProjGraph.Mcp` are pointer packages that list one package per runtime identifier. +`dotnet tool install` and `dnx` pick the Native AOT package on win-x64, linux-x64, linux-arm64, +linux-musl-x64, linux-musl-arm64, and osx-arm64, and the framework-dependent `.any` package elsewhere. +Installing a pointer package fails until every package it lists is on the feed, which is why `publish` +pushes it last. + +If a native package misbehaves after a release, remove its RID from `ToolPackageRuntimeIdentifiers` in +both tool projects and ship a patch; that platform then falls back to `any`. Remove a `linux-` +RID together with its `linux-musl-` RID, and never a musl RID alone. The RID graph treats musl +as compatible with glibc, so musl systems would install the glibc package, which can't start there. + ### MCP Registry Ownership Verification The Official MCP Registry verifies package ownership before accepting a submission by scanning the NuGet package diff --git a/CLAUDE.md b/CLAUDE.md index 5eda88e..67e1108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,4 +90,4 @@ Application/ ### Release -Releases are triggered by pushing a `v*` tag. The publish workflow builds, packs, pushes to NuGet.org and GitHub Packages, submits to the MCP Registry via `mcp-publisher`, and creates a GitHub Release. The MCP Registry ownership comment (``) must remain at the end of `src/ProjGraph.Mcp/README.md`. +Releases are triggered by pushing a `v*` tag. `publish.yml` builds and tests with `-p:Version` from the tag, then calls `pack.yml`. That workflow packs and smoke-tests the Native AOT tool packages on a matching runner for each RID (Alpine for `linux-musl-*`) and packs the libraries, the `any` fallbacks, and the two pointer packages. `publish` then pushes everything except the pointer packages to NuGet.org and GitHub Packages, waits until NuGet.org lists all 14 tool packages, pushes the pointer packages, creates a GitHub Release, and submits to the MCP Registry via `mcp-publisher`. See `ARCHITECTURE.md` ("Tool Packages") before changing `ToolPackageRuntimeIdentifiers`. The MCP Registry ownership comment (``) must remain at the end of `src/ProjGraph.Mcp/README.md`. From 920d963bc984a2cfe0120d76cf08d5f005941bf6 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 01:38:06 +0200 Subject: [PATCH 10/15] docs: correct the release package count in the PR 3 plan Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-09-14-native-aot-pr3-packaging-release.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md index 71e5b35..82ce6f2 100644 --- a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md +++ b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md @@ -1774,7 +1774,7 @@ Expected: no output, exit 0. The old file's `SC2086` infos are gone. With `publish.yml` open, confirm: - (a) No step before `Wait for the tool packages on NuGet.org` touches `artifacts/pointers` except the download and verify steps. - (b) The wait's ID list has 14 entries and matches `verify-packages.sh`'s `any` and six RID sets. -- (c) The verify step's sets (`libraries any` + 6 RIDs = 16 files, `pointers` = 2) add up to the 22 packages in Global Constraints. +- (c) The verify step's sets (`libraries any` + 6 RIDs = 20 files, `pointers` = 2) add up to the 22 packages in Global Constraints. Every list must use the same RID spellings. A mismatch here would only surface on a release tag. @@ -1925,7 +1925,7 @@ These publish public packages or change settings, so they belong to the user. Pu 1. **NuGet.org API key scope.** Confirm that the key behind `NUGET_API_KEY` can push the 14 new package IDs, i.e. its glob covers `ProjGraph.*` (spec risk table). If it lists explicit package IDs, the release fails at the first native push. 2. **Make `aot-smoke` required** on `develop`. This is still pending from PR 2; the ruleset has no required status checks yet. 3. **RC dry run** (spec Testing table). Push a pre-release tag such as `v1.2.0-rc.1` on the merge commit and watch `publish.yml`. The `publish` log must show: - - `… holds the 16 expected packages.` and `… holds the 2 expected packages.`; + - `… holds the 20 expected packages.` and `… holds the 2 expected packages.`; - the package pushes; - `is listed on NuGet.org.` for all 14 IDs; - only then the pointer pushes, the GitHub Release, and the MCP Registry publish. From 234c0aec04a0744b84453c4e38f326c12a2e2e9c Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 01:47:07 +0200 Subject: [PATCH 11/15] fix(ci): stamp the MCP server.json version before packing Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/native-tool-smoke/action.yml | 6 ++++ .github/scripts/set-server-json-version.sh | 28 +++++++++++++++++++ .github/workflows/pack.yml | 4 +++ .github/workflows/publish.yml | 5 +--- ...-09-14-native-aot-pr3-packaging-release.md | 2 ++ 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100755 .github/scripts/set-server-json-version.sh diff --git a/.github/actions/native-tool-smoke/action.yml b/.github/actions/native-tool-smoke/action.yml index 59694b5..74319eb 100644 --- a/.github/actions/native-tool-smoke/action.yml +++ b/.github/actions/native-tool-smoke/action.yml @@ -31,6 +31,12 @@ runs: sudo apt-get update sudo apt-get install -y --no-install-recommends clang zlib1g-dev + - name: Stamp the MCP server.json version + shell: bash + env: + VERSION: ${{ inputs.version }} + run: bash .github/scripts/set-server-json-version.sh "$VERSION" + - name: Pack and smoke test if: ${{ !startsWith(inputs.rid, 'linux-musl-') }} shell: bash diff --git a/.github/scripts/set-server-json-version.sh b/.github/scripts/set-server-json-version.sh new file mode 100755 index 0000000..8f240f7 --- /dev/null +++ b/.github/scripts/set-server-json-version.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Stamps into every "version" field in src/ProjGraph.Mcp/.mcp/server.json: the top-level +# server version and the packaged ProjGraph.Mcp version. Every ProjGraph.Mcp* package embeds this +# file, so it must run before packing, not only once in publish. +# Usage: set-server-json-version.sh +# Keep it Bash 3.2 compatible: it also runs on macOS (osx-arm64 pack job). +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +version=$1 +file=src/ProjGraph.Mcp/.mcp/server.json + +# -i.bak works on both GNU sed (Linux, Windows Git Bash) and BSD sed (macOS); [^\"]* replaces +# GNU-only \+ so the pattern matches on both too. +sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$version\"/g" "$file" && rm "$file.bak" + +echo "Set server.json version to $version:" +grep '"version"' "$file" + +count=$(grep -c "\"version\": \"$version\"" "$file") +if [ "$count" -ne 2 ]; then + echo "::error::Expected 2 occurrences of \"version\": \"$version\" in $file, found $count." >&2 + exit 1 +fi diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index 0fa74fc..891a77e 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -21,6 +21,7 @@ on: - .github/actions/native-tool-smoke/** - .github/scripts/native-tool-smoke.sh - .github/scripts/verify-packages.sh + - .github/scripts/set-server-json-version.sh - src/ProjGraph.Cli/ProjGraph.Cli.csproj - src/ProjGraph.Mcp/ProjGraph.Mcp.csproj - tests/ProjGraph.Tests.Smoke.Aot/** @@ -107,6 +108,9 @@ jobs: dotnet restore ProjGraph.slnx dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" + - name: Stamp the MCP server.json version + run: bash .github/scripts/set-server-json-version.sh "$VERSION" + # With PublishAot set, packing the solution produces the RID-agnostic pointer packages for the # two tools; they move to their own folder because publish.yml pushes them last. - name: Pack libraries and pointer packages diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f2fc2ba..dcb406e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -157,10 +157,7 @@ jobs: prerelease: ${{ contains(needs.prepare.outputs.tag, '-') }} - name: Update server.json version - run: | - sed -i "s/\"version\": \"[^\"]\+\"/\"version\": \"$VERSION\"/g" src/ProjGraph.Mcp/.mcp/server.json - echo "Updated server.json:" - grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json + run: bash .github/scripts/set-server-json-version.sh "$VERSION" - name: Install mcp-publisher run: | diff --git a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md index 82ce6f2..86e8663 100644 --- a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md +++ b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md @@ -62,6 +62,7 @@ A new reusable workflow, `pack.yml`, runs the action for all six native RIDs and - split pack steps; - TRX upload on failure. 12. **Plain JIT builds now carry AOT feature switches.** With `PublishAot` in the csproj, `dotnet build` writes `IsDynamicCodeSupported=false`, `JsonSerializer.IsReflectionEnabledByDefault=false`, and similar switches into `ProjGraph.Cli.runtimeconfig.json`/`ProjGraph.Mcp.runtimeconfig.json`. The smoke reference and `dotnet run` therefore run with them. The `any` packages (`-p:PublishAot=false`) don't, which is why deviation 8 tests them separately. +13. **The MCP `server.json` version is stamped before packing.** `.github/scripts/set-server-json-version.sh` runs in the composite action, in `pack-portable`, and in `publish`, because every `ProjGraph.Mcp*` package embeds `.mcp/server.json`. With the spec's sed only in `publish`, all 8 MCP packages would ship the committed `0.4.0`. ## Verified facts this plan relies on @@ -140,6 +141,7 @@ Checked on 2026-09-14 in throwaway copies of `develop` at `fe0f3fd` (linux-x64, | `.github/actions/native-tool-smoke/action.yml` | Create | Runs the script on the runner, or in Alpine for `linux-musl-*` | | `.github/scripts/verify-packages.sh` | Create | Requires a folder to hold exactly the named package sets | | `.github/scripts/wait-for-nuget.sh` | Create | Polls NuGet.org's flat container until versions are listed | +| `.github/scripts/set-server-json-version.sh` | Create | Stamps the version into `.mcp/server.json` before every pack | | `.github/workflows/ci.yml` | Modify | `aot-smoke` uses the composite action | | `.github/workflows/pack.yml` | Create | `pack-native` ×6 and `pack-portable`; reusable, also PR-triggered | | `.github/workflows/publish.yml` | Modify | `prepare` → `pack` → `publish` with ordered pushes | From 8ef51544c2dd1860c2e9f84a118c81b50a5747a6 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 02:17:49 +0200 Subject: [PATCH 12/15] ci: derive the release wait list from the pointer packages Add pointer-package-ids.sh, which prints the IDs each pointer package lists in its DotnetToolSettings.xml. publish now fails before any push if a pointer lists a package that wasn't built, and it waits on NuGet.org for exactly those IDs instead of a hand-written list. Also harden the release workflows from the final review: - publish: read-only top-level permissions, write permissions only on the publish job, job timeouts, tag validation for pushed tags, and re-run guidance in the workflow_dispatch comment. - pack: fallback smoke TRX upload on failure, wider pull request paths, and a concurrency group keyed on the release tag. - set-server-json-version.sh prints its error when no version matches. - wait-for-nuget.sh caps each request at 30 seconds. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/pointer-package-ids.sh | 48 ++++++++++++++++++++++ .github/scripts/set-server-json-version.sh | 5 ++- .github/scripts/wait-for-nuget.sh | 2 +- .github/workflows/pack.yml | 21 ++++++++-- .github/workflows/publish.yml | 47 +++++++++++++++------ 5 files changed, 103 insertions(+), 20 deletions(-) create mode 100755 .github/scripts/pointer-package-ids.sh diff --git a/.github/scripts/pointer-package-ids.sh b/.github/scripts/pointer-package-ids.sh new file mode 100755 index 0000000..f3c6e89 --- /dev/null +++ b/.github/scripts/pointer-package-ids.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Prints the ID of every package the pointer packages in a directory list, one per line, sorted and +# unique. A pointer package lists them in tools/any/any/DotnetToolSettings.xml, as one +# element per runtime identifier. +# Usage: pointer-package-ids.sh +# Keep it Bash 3.2 compatible, like the other packaging scripts. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +directory=$1 +settings_path=tools/any/any/DotnetToolSettings.xml +ids="" +packages=0 + +for package in "$directory"/*.nupkg; do + if [ ! -e "$package" ]; then + continue + fi + packages=$((packages + 1)) + + if ! settings=$(unzip -p "$package" "$settings_path" 2> /dev/null) || [ -z "$settings" ]; then + echo "::error::$package has no $settings_path." >&2 + exit 1 + fi + + # Joining the lines first keeps an element that spans several lines matchable. + package_ids=$(printf '%s\n' "$settings" | tr -s '[:space:]' ' ' | + grep -o ']*>' | + grep -o '[[:space:]]Id="[^"]*"' | + sed 's/^[[:space:]]Id="//; s/"$//' || true) + if [ -z "$package_ids" ]; then + echo "::error::$package lists no RuntimeIdentifierPackage IDs in $settings_path." >&2 + exit 1 + fi + + ids="$ids$package_ids"$'\n' +done + +if [ "$packages" -eq 0 ]; then + echo "::error::$directory holds no .nupkg files." >&2 + exit 1 +fi + +printf '%s' "$ids" | LC_ALL=C sort -u diff --git a/.github/scripts/set-server-json-version.sh b/.github/scripts/set-server-json-version.sh index 8f240f7..ad458a9 100755 --- a/.github/scripts/set-server-json-version.sh +++ b/.github/scripts/set-server-json-version.sh @@ -19,9 +19,10 @@ file=src/ProjGraph.Mcp/.mcp/server.json sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$version\"/g" "$file" && rm "$file.bak" echo "Set server.json version to $version:" -grep '"version"' "$file" +# grep exits 1 when nothing matches; `|| true` on both lets the error below report the zero count. +grep '"version"' "$file" || true -count=$(grep -c "\"version\": \"$version\"" "$file") +count=$(grep -c "\"version\": \"$version\"" "$file" || true) if [ "$count" -ne 2 ]; then echo "::error::Expected 2 occurrences of \"version\": \"$version\" in $file, found $count." >&2 exit 1 diff --git a/.github/scripts/wait-for-nuget.sh b/.github/scripts/wait-for-nuget.sh index d2d04ca..fcd6154 100755 --- a/.github/scripts/wait-for-nuget.sh +++ b/.github/scripts/wait-for-nuget.sh @@ -21,7 +21,7 @@ while :; do waiting=() for id in "${pending[@]}"; do lower_id=$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]') - if curl --silent --fail "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" | + if curl --silent --fail --max-time 30 "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" | jq --exit-status --arg version "$version" '.versions | index($version) != null' > /dev/null; then echo "$id $version is listed on NuGet.org." else diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index 891a77e..feaa7dc 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -22,9 +22,10 @@ on: - .github/scripts/native-tool-smoke.sh - .github/scripts/verify-packages.sh - .github/scripts/set-server-json-version.sh - - src/ProjGraph.Cli/ProjGraph.Cli.csproj - - src/ProjGraph.Mcp/ProjGraph.Mcp.csproj + - src/**/*.csproj + - src/ProjGraph.Mcp/.mcp/server.json - tests/ProjGraph.Tests.Smoke.Aot/** + - ProjGraph.slnx - Directory.Build.props - Directory.Packages.props - global.json @@ -32,8 +33,10 @@ on: permissions: contents: read +# Inside workflow_call, github.ref is the caller's ref, which for a dispatched release is the branch it +# ran from, so the group uses the release tag to keep runs for different tags apart. concurrency: - group: pack-${{ github.event_name }}-${{ github.ref }} + group: pack-${{ github.event_name }}-${{ inputs.ref || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: @@ -144,7 +147,17 @@ jobs: unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" \ - --logger "console;verbosity=normal" + --logger "console;verbosity=normal" --logger "trx;LogFileName=fallback-smoke.trx" \ + --results-directory artifacts/smoke-results + + - name: Upload fallback smoke test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-results-portable + path: artifacts/smoke-results + if-no-files-found: ignore + retention-days: 14 - name: Upload libraries and fallback packages uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dcb406e..affe341 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,9 @@ on: tags: - 'v*' # Allow re-running a release on demand (e.g. after a transient NuGet/MCP Registry failure). + # For a transient publish failure, prefer "Re-run failed jobs" on the original run: it reuses that + # run's packages. A new dispatch rebuilds every package and replaces the GitHub Release assets. + # Dispatch from the tag ref, so the workflow files match the scripts checked out from the tag. # The tag must already exist: every job below derives the version from it. Pushes skip packages # that are already on a feed, and the NuGet.org wait passes at once for packages already listed. workflow_dispatch: @@ -15,22 +18,23 @@ on: type: string permissions: - contents: write - packages: write - id-token: write # Required for MCP Registry OIDC authentication + contents: read jobs: prepare: runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read outputs: tag: ${{ steps.get_version.outputs.tag }} version: ${{ steps.get_version.outputs.version }} steps: - - name: Validate tag input - if: github.event_name == 'workflow_dispatch' + # Runs for pushed tags too, so a malformed tag fails before anything is built or used in a command. + - name: Validate tag env: - TAG: ${{ github.event.inputs.tag }} + TAG: ${{ github.event.inputs.tag || github.ref_name }} run: | if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "::error::'$TAG' is not a valid release tag (expected e.g. v1.0.3 or v1.0.0-rc.1)." @@ -73,6 +77,8 @@ jobs: pack: needs: prepare + permissions: + contents: read uses: ./.github/workflows/pack.yml with: ref: ${{ needs.prepare.outputs.tag }} @@ -81,6 +87,13 @@ jobs: publish: needs: [prepare, pack] runs-on: ubuntu-latest + # Worst case: the 30-minute NuGet.org wait, the 5-minute validation sleep, and 5 × 15-minute + # MCP Registry attempts. + timeout-minutes: 150 + permissions: + contents: write + packages: write + id-token: write # Required for MCP Registry OIDC authentication env: VERSION: ${{ needs.prepare.outputs.version }} @@ -106,14 +119,22 @@ jobs: run: | bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any win-x64 linux-x64 linux-arm64 linux-musl-x64 linux-musl-arm64 osx-arm64 bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers + pointer_ids=$(bash .github/scripts/pointer-package-ids.sh artifacts/pointers) + for id in $pointer_ids; do + if [ ! -e "artifacts/packages/$id.$VERSION.nupkg" ]; then + echo "::error::A pointer package lists $id, but artifacts/packages has no $id.$VERSION.nupkg." + exit 1 + fi + done + echo "Every package the pointers list is present." - name: Setup .NET uses: actions/setup-dotnet@v6 with: global-json-file: global.json - # A pointer package names its RID-specific packages, and installing it fails until they exist, so - # every other package goes first. + # A pointer package names its RID-specific packages, and installing it fails on a machine whose + # RID package isn't on the feed yet, so every other package goes first. - name: Publish packages to NuGet.org env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} @@ -125,13 +146,13 @@ jobs: OWNER: ${{ github.repository_owner }} run: dotnet nuget push "artifacts/packages/*.nupkg" --source "https://nuget.pkg.github.com/$OWNER/index.json" --api-key "$GITHUB_TOKEN" --skip-duplicate + # Waits for exactly the packages the pointer packages list. - name: Wait for the tool packages on NuGet.org run: | - bash .github/scripts/wait-for-nuget.sh "$VERSION" \ - ProjGraph.Cli.any ProjGraph.Cli.win-x64 ProjGraph.Cli.linux-x64 ProjGraph.Cli.linux-arm64 \ - ProjGraph.Cli.linux-musl-x64 ProjGraph.Cli.linux-musl-arm64 ProjGraph.Cli.osx-arm64 \ - ProjGraph.Mcp.any ProjGraph.Mcp.win-x64 ProjGraph.Mcp.linux-x64 ProjGraph.Mcp.linux-arm64 \ - ProjGraph.Mcp.linux-musl-x64 ProjGraph.Mcp.linux-musl-arm64 ProjGraph.Mcp.osx-arm64 + pointer_ids=$(bash .github/scripts/pointer-package-ids.sh artifacts/pointers) + ids=() + while IFS= read -r id; do ids+=("$id"); done <<< "$pointer_ids" + bash .github/scripts/wait-for-nuget.sh "$VERSION" "${ids[@]}" - name: Publish pointer packages to NuGet.org env: From be644728d1f2a928010da80d55fb4336577c3046 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 02:17:49 +0200 Subject: [PATCH 13/15] docs: list every RID sync point and align the PR 3 plan ARCHITECTURE.md names the four places a RID lives, says publish derives its wait list from the pointer packages, and updates the CI line and the release flow (server.json stamps, NuGet.org validation wait). Both tool project comments point at the other RID lists. CONTRIBUTING.md warns that a local native pack ships a stale publish folder. The PR 3 plan's workflow, action, and script blocks now match the committed files, and it records deviation 14. Co-Authored-By: Claude Opus 5 (1M context) --- ARCHITECTURE.md | 38 ++++-- CONTRIBUTING.md | 4 + ...-09-14-native-aot-pr3-packaging-release.md | 108 +++++++++++++----- src/ProjGraph.Cli/ProjGraph.Cli.csproj | 4 +- src/ProjGraph.Mcp/ProjGraph.Mcp.csproj | 4 +- 5 files changed, 115 insertions(+), 43 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3329ee..cd99071 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,7 +104,8 @@ Class analysis uses Roslyn to: - **Target Framework**: .NET 10.0 - **Central Package Management**: `Directory.Packages.props` - **Code Quality**: `TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true` -- **CI**: GitHub Actions on `ubuntu-latest` and `windows-latest` +- **CI**: GitHub Actions. `ci.yml` builds and tests on `ubuntu-latest`, `windows-latest`, and `macos-latest`, plus + the `aot-smoke` job; `pack.yml` adds `ubuntu-24.04-arm` runners and Alpine containers for the native packages ## Release & Distribution @@ -119,32 +120,47 @@ Tag push (v*) ├── pack (.github/workflows/pack.yml) │ ├── pack-native ×6, each on a matching runner (linux-musl-* inside Alpine): │ │ win-x64 · linux-x64 · linux-arm64 · linux-musl-x64 · linux-musl-arm64 · osx-arm64 - │ │ dotnet pack -r → dotnet tool install from the packages → Tests.Smoke.Aot - │ └── pack-portable: libraries, pointer packages, framework-dependent `any` packages → Tests.Smoke.Aot + │ │ stamp the server.json version → dotnet pack -r + │ │ → dotnet tool install from the packages → Tests.Smoke.Aot + │ └── pack-portable: stamp the server.json version → libraries, pointer packages, + │ framework-dependent `any` packages → Tests.Smoke.Aot └── publish ├── dotnet nuget push libraries + native + any → NuGet.org, GitHub Packages ├── wait until NuGet.org lists all 14 tool packages (30 min timeout) ├── dotnet nuget push pointer packages → NuGet.org, GitHub Packages ├── Create GitHub Release with every package attached + ├── stamp the server.json version (.github/scripts/set-server-json-version.sh) + ├── wait 300 s for NuGet.org package validation └── mcp-publisher publish (GitHub OIDC auth, no token required) └── Submits src/ProjGraph.Mcp/.mcp/server.json to the Official MCP Registry ``` -`pack.yml` also runs on pull requests that change the tool projects, the smoke suite, the packaging -scripts, `Directory.*.props`, or `global.json`. +`pack.yml` also runs on pull requests that change a project file under `src/`, the MCP `server.json`, +`ProjGraph.slnx`, the smoke suite, the packaging scripts, `Directory.*.props`, or `global.json`. ### Tool Packages `ProjGraph.Cli` and `ProjGraph.Mcp` are pointer packages that list one package per runtime identifier. `dotnet tool install` and `dnx` pick the Native AOT package on win-x64, linux-x64, linux-arm64, linux-musl-x64, linux-musl-arm64, and osx-arm64, and the framework-dependent `.any` package elsewhere. -Installing a pointer package fails until every package it lists is on the feed, which is why `publish` -pushes it last. +Installing a pointer package fails when the package for the installing machine's RID isn't on the feed +(it doesn't fall back to `any`), which is why `publish` pushes it last. -If a native package misbehaves after a release, remove its RID from `ToolPackageRuntimeIdentifiers` in -both tool projects and ship a patch; that platform then falls back to `any`. Remove a `linux-` -RID together with its `linux-musl-` RID, and never a musl RID alone. The RID graph treats musl -as compatible with glibc, so musl systems would install the glibc package, which can't start there. +The RID list lives in four places, and they must match: + +- `ToolPackageRuntimeIdentifiers` in both tool projects (`src/ProjGraph.Cli/ProjGraph.Cli.csproj` and + `src/ProjGraph.Mcp/ProjGraph.Mcp.csproj`); +- the `pack-native` matrix in `.github/workflows/pack.yml`; +- the RID sets in `.github/scripts/verify-packages.sh`; +- the verify line in `.github/workflows/publish.yml`. + +`publish` derives the NuGet.org wait list from the pointer packages (`.github/scripts/pointer-package-ids.sh`), +and it fails before pushing anything if a pointer lists a package that wasn't built. + +If a native package misbehaves after a release, remove its RID from all four places and ship a patch; +that platform then installs the `any` package. Remove a `linux-` RID together with its +`linux-musl-` RID, and never a musl RID alone. The RID graph treats musl as compatible with +glibc, so musl systems would install the glibc package, which can't start there. ### MCP Registry Ownership Verification diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d411a4..c2ba055 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,10 @@ packs the native tool packages, installs them with `dotnet tool install`, and te tools. The `aot-smoke` job does this for linux-x64 on every PR, and `pack.yml` does it for every release platform. Native AOT on Linux needs `clang` (or `gcc`) and `zlib1g-dev`. +Before a local native pack or publish, delete `src/ProjGraph.Cli/bin/Release/net10.0/` and +`src/ProjGraph.Mcp/bin/Release/net10.0/`: a pack reuses that folder's `publish/` output and ships +whatever is left there. + To run the same check locally on Linux: ```bash diff --git a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md index 86e8663..46252d1 100644 --- a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md +++ b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md @@ -63,6 +63,7 @@ A new reusable workflow, `pack.yml`, runs the action for all six native RIDs and - TRX upload on failure. 12. **Plain JIT builds now carry AOT feature switches.** With `PublishAot` in the csproj, `dotnet build` writes `IsDynamicCodeSupported=false`, `JsonSerializer.IsReflectionEnabledByDefault=false`, and similar switches into `ProjGraph.Cli.runtimeconfig.json`/`ProjGraph.Mcp.runtimeconfig.json`. The smoke reference and `dotnet run` therefore run with them. The `any` packages (`-p:PublishAot=false`) don't, which is why deviation 8 tests them separately. 13. **The MCP `server.json` version is stamped before packing.** `.github/scripts/set-server-json-version.sh` runs in the composite action, in `pack-portable`, and in `publish`, because every `ProjGraph.Mcp*` package embeds `.mcp/server.json`. With the spec's sed only in `publish`, all 8 MCP packages would ship the committed `0.4.0`. +14. **The NuGet.org wait list comes from the pointer packages.** `.github/scripts/pointer-package-ids.sh` reads each pointer's `tools/any/any/DotnetToolSettings.xml`. `publish` fails before any push if a listed package wasn't built, and it waits for exactly the listed IDs instead of a hand-written list. A RID added only to `ToolPackageRuntimeIdentifiers` therefore stops the release instead of shipping a pointer whose package for that RID doesn't exist. ## Verified facts this plan relies on @@ -142,6 +143,7 @@ Checked on 2026-09-14 in throwaway copies of `develop` at `fe0f3fd` (linux-x64, | `.github/scripts/verify-packages.sh` | Create | Requires a folder to hold exactly the named package sets | | `.github/scripts/wait-for-nuget.sh` | Create | Polls NuGet.org's flat container until versions are listed | | `.github/scripts/set-server-json-version.sh` | Create | Stamps the version into `.mcp/server.json` before every pack | +| `.github/scripts/pointer-package-ids.sh` | Create | Prints the package IDs the pointer packages list, for `publish`'s verify step and NuGet.org wait | | `.github/workflows/ci.yml` | Modify | `aot-smoke` uses the composite action | | `.github/workflows/pack.yml` | Create | `pack-native` ×6 and `pack-portable`; reusable, also PR-triggered | | `.github/workflows/publish.yml` | Modify | `prepare` → `pack` → `publish` with ordered pushes | @@ -977,6 +979,12 @@ runs: sudo apt-get update sudo apt-get install -y --no-install-recommends clang zlib1g-dev + - name: Stamp the MCP server.json version + shell: bash + env: + VERSION: ${{ inputs.version }} + run: bash .github/scripts/set-server-json-version.sh "$VERSION" + - name: Pack and smoke test if: ${{ !startsWith(inputs.rid, 'linux-musl-') }} shell: bash @@ -1294,9 +1302,11 @@ on: - .github/actions/native-tool-smoke/** - .github/scripts/native-tool-smoke.sh - .github/scripts/verify-packages.sh - - src/ProjGraph.Cli/ProjGraph.Cli.csproj - - src/ProjGraph.Mcp/ProjGraph.Mcp.csproj + - .github/scripts/set-server-json-version.sh + - src/**/*.csproj + - src/ProjGraph.Mcp/.mcp/server.json - tests/ProjGraph.Tests.Smoke.Aot/** + - ProjGraph.slnx - Directory.Build.props - Directory.Packages.props - global.json @@ -1304,8 +1314,10 @@ on: permissions: contents: read +# Inside workflow_call, github.ref is the caller's ref, which for a dispatched release is the branch it +# ran from, so the group uses the release tag to keep runs for different tags apart. concurrency: - group: pack-${{ github.event_name }}-${{ github.ref }} + group: pack-${{ github.event_name }}-${{ inputs.ref || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: @@ -1380,6 +1392,9 @@ jobs: dotnet restore ProjGraph.slnx dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" + - name: Stamp the MCP server.json version + run: bash .github/scripts/set-server-json-version.sh "$VERSION" + # With PublishAot set, packing the solution produces the RID-agnostic pointer packages for the # two tools; they move to their own folder because publish.yml pushes them last. - name: Pack libraries and pointer packages @@ -1413,7 +1428,17 @@ jobs: unzip -q "artifacts/packages/ProjGraph.Mcp.any.$VERSION.nupkg" -d artifacts/fallback/mcp dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" \ - --logger "console;verbosity=normal" + --logger "console;verbosity=normal" --logger "trx;LogFileName=fallback-smoke.trx" \ + --results-directory artifacts/smoke-results + + - name: Upload fallback smoke test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-results-portable + path: artifacts/smoke-results + if-no-files-found: ignore + retention-days: 14 - name: Upload libraries and fallback packages uses: actions/upload-artifact@v7 @@ -1450,6 +1475,7 @@ cd /tmp/pg-pr3-portable export VERSION=0.0.0-local.4 dotnet restore ProjGraph.slnx dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$VERSION" +bash .github/scripts/set-server-json-version.sh "$VERSION" dotnet pack ProjGraph.slnx --no-build --configuration Release -p:Version="$VERSION" --output artifacts/packages mkdir -p artifacts/pointers mv "artifacts/packages/ProjGraph.Cli.$VERSION.nupkg" "artifacts/packages/ProjGraph.Mcp.$VERSION.nupkg" artifacts/pointers/ @@ -1467,7 +1493,9 @@ PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cl PROJGRAPH_SMOKE_MCP_NATIVE=artifacts/fallback/mcp/tools/net10.0/any/ProjGraph.Mcp.dll \ PROJGRAPH_SMOKE_MCP_REFERENCE=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll \ dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ - --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" + --filter "FullyQualifiedName!~ProjGraph.Tests.Smoke.Aot.NativeBuildGuardTests" \ + --logger "console;verbosity=normal" --logger "trx;LogFileName=fallback-smoke.trx" \ + --results-directory artifacts/smoke-results unset VERSION cd - && rm -rf /tmp/pg-pr3-portable ``` @@ -1491,6 +1519,7 @@ git commit -m "ci: fix the pack workflow" **Files:** - Create: `.github/scripts/wait-for-nuget.sh` +- Create: `.github/scripts/pointer-package-ids.sh` (final-review fix, deviation 14) - Modify: `.github/workflows/publish.yml` - Modify: `ARCHITECTURE.md`, `CLAUDE.md` @@ -1526,7 +1555,7 @@ while :; do waiting=() for id in "${pending[@]}"; do lower_id=$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]') - if curl --silent --fail "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" | + if curl --silent --fail --max-time 30 "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" | jq --exit-status --arg version "$version" '.versions | index($version) != null' > /dev/null; then echo "$id $version is listed on NuGet.org." else @@ -1585,6 +1614,9 @@ on: tags: - 'v*' # Allow re-running a release on demand (e.g. after a transient NuGet/MCP Registry failure). + # For a transient publish failure, prefer "Re-run failed jobs" on the original run: it reuses that + # run's packages. A new dispatch rebuilds every package and replaces the GitHub Release assets. + # Dispatch from the tag ref, so the workflow files match the scripts checked out from the tag. # The tag must already exist: every job below derives the version from it. Pushes skip packages # that are already on a feed, and the NuGet.org wait passes at once for packages already listed. workflow_dispatch: @@ -1595,22 +1627,23 @@ on: type: string permissions: - contents: write - packages: write - id-token: write # Required for MCP Registry OIDC authentication + contents: read jobs: prepare: runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read outputs: tag: ${{ steps.get_version.outputs.tag }} version: ${{ steps.get_version.outputs.version }} steps: - - name: Validate tag input - if: github.event_name == 'workflow_dispatch' + # Runs for pushed tags too, so a malformed tag fails before anything is built or used in a command. + - name: Validate tag env: - TAG: ${{ github.event.inputs.tag }} + TAG: ${{ github.event.inputs.tag || github.ref_name }} run: | if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "::error::'$TAG' is not a valid release tag (expected e.g. v1.0.3 or v1.0.0-rc.1)." @@ -1653,6 +1686,8 @@ jobs: pack: needs: prepare + permissions: + contents: read uses: ./.github/workflows/pack.yml with: ref: ${{ needs.prepare.outputs.tag }} @@ -1661,6 +1696,13 @@ jobs: publish: needs: [prepare, pack] runs-on: ubuntu-latest + # Worst case: the 30-minute NuGet.org wait, the 5-minute validation sleep, and 5 × 15-minute + # MCP Registry attempts. + timeout-minutes: 150 + permissions: + contents: write + packages: write + id-token: write # Required for MCP Registry OIDC authentication env: VERSION: ${{ needs.prepare.outputs.version }} @@ -1686,14 +1728,22 @@ jobs: run: | bash .github/scripts/verify-packages.sh artifacts/packages "$VERSION" libraries any win-x64 linux-x64 linux-arm64 linux-musl-x64 linux-musl-arm64 osx-arm64 bash .github/scripts/verify-packages.sh artifacts/pointers "$VERSION" pointers + pointer_ids=$(bash .github/scripts/pointer-package-ids.sh artifacts/pointers) + for id in $pointer_ids; do + if [ ! -e "artifacts/packages/$id.$VERSION.nupkg" ]; then + echo "::error::A pointer package lists $id, but artifacts/packages has no $id.$VERSION.nupkg." + exit 1 + fi + done + echo "Every package the pointers list is present." - name: Setup .NET uses: actions/setup-dotnet@v6 with: global-json-file: global.json - # A pointer package names its RID-specific packages, and installing it fails until they exist, so - # every other package goes first. + # A pointer package names its RID-specific packages, and installing it fails on a machine whose + # RID package isn't on the feed yet, so every other package goes first. - name: Publish packages to NuGet.org env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} @@ -1705,13 +1755,13 @@ jobs: OWNER: ${{ github.repository_owner }} run: dotnet nuget push "artifacts/packages/*.nupkg" --source "https://nuget.pkg.github.com/$OWNER/index.json" --api-key "$GITHUB_TOKEN" --skip-duplicate + # Waits for exactly the packages the pointer packages list. - name: Wait for the tool packages on NuGet.org run: | - bash .github/scripts/wait-for-nuget.sh "$VERSION" \ - ProjGraph.Cli.any ProjGraph.Cli.win-x64 ProjGraph.Cli.linux-x64 ProjGraph.Cli.linux-arm64 \ - ProjGraph.Cli.linux-musl-x64 ProjGraph.Cli.linux-musl-arm64 ProjGraph.Cli.osx-arm64 \ - ProjGraph.Mcp.any ProjGraph.Mcp.win-x64 ProjGraph.Mcp.linux-x64 ProjGraph.Mcp.linux-arm64 \ - ProjGraph.Mcp.linux-musl-x64 ProjGraph.Mcp.linux-musl-arm64 ProjGraph.Mcp.osx-arm64 + pointer_ids=$(bash .github/scripts/pointer-package-ids.sh artifacts/pointers) + ids=() + while IFS= read -r id; do ids+=("$id"); done <<< "$pointer_ids" + bash .github/scripts/wait-for-nuget.sh "$VERSION" "${ids[@]}" - name: Publish pointer packages to NuGet.org env: @@ -1737,10 +1787,7 @@ jobs: prerelease: ${{ contains(needs.prepare.outputs.tag, '-') }} - name: Update server.json version - run: | - sed -i "s/\"version\": \"[^\"]\+\"/\"version\": \"$VERSION\"/g" src/ProjGraph.Mcp/.mcp/server.json - echo "Updated server.json:" - grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json + run: bash .github/scripts/set-server-json-version.sh "$VERSION" - name: Install mcp-publisher run: | @@ -1762,8 +1809,9 @@ jobs: What changed compared with the old file, so a reviewer can check it quickly: - The `Update Directory.Build.props version` step is gone. Every build and pack gets `-p:Version` instead, because jobs don't share a workspace. -- `server.json` is still edited with `sed`, now only in `publish` (spec 3.2), and `identifier` stays `ProjGraph.Mcp`, which is now the pointer package. -- The wait lists all 14 tool sub-packages, and pointer pushes come after it. +- The `server.json` version is stamped by `.github/scripts/set-server-json-version.sh` before packing (in the composite action and in `pack-portable`) and again in `publish` (deviation 13). `identifier` stays `ProjGraph.Mcp`, which is now the pointer package. +- The verify step also fails if a pointer package lists a package that wasn't built, and the wait covers exactly the IDs the pointer packages list (14 today, from `pointer-package-ids.sh`, deviation 14). Pointer pushes come after it. +- The workflow's top-level permissions are read-only; only `publish` gets `contents`/`packages`/`id-token: write`. The tag format is validated for pushed tags too. ```bash docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false .github/workflows/publish.yml .github/workflows/pack.yml .github/workflows/ci.yml @@ -1775,7 +1823,7 @@ Expected: no output, exit 0. The old file's `SC2086` infos are gone. With `publish.yml` open, confirm: - (a) No step before `Wait for the tool packages on NuGet.org` touches `artifacts/pointers` except the download and verify steps. -- (b) The wait's ID list has 14 entries and matches `verify-packages.sh`'s `any` and six RID sets. +- (b) The wait's ID list comes from `pointer-package-ids.sh artifacts/pointers`, which prints 14 IDs matching `verify-packages.sh`'s `any` and six RID sets. - (c) The verify step's sets (`libraries any` + 6 RIDs = 20 files, `pointers` = 2) add up to the 22 packages in Global Constraints. Every list must use the same RID spellings. A mismatch here would only surface on a release tag. @@ -1784,7 +1832,7 @@ Every list must use the same RID spellings. A mismatch here would only surface o grep -o 'linux-musl-arm64' .github/workflows/pack.yml .github/workflows/publish.yml .github/scripts/verify-packages.sh src/ProjGraph.Cli/ProjGraph.Cli.csproj src/ProjGraph.Mcp/ProjGraph.Mcp.csproj | sort | uniq -c ``` -Expected: `pack.yml` has 1, `publish.yml` 3 (verify line + 2 wait IDs), `verify-packages.sh` 2 (comment + case), and each csproj 1. +Expected: `pack.yml` has 1, `publish.yml` 1 (verify line; the wait IDs come from the pointer packages), `verify-packages.sh` 2 (comment + case), and each csproj 1. - [ ] **Step 5: Update the release docs** @@ -1860,7 +1908,7 @@ dotnet build ProjGraph.slnx -c Release dotnet format ProjGraph.slnx --no-restore --verify-no-changes dotnet test ProjGraph.slnx -c Release --no-build docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color=false .github/workflows/ci.yml .github/workflows/pack.yml .github/workflows/publish.yml -docker run --rm --user "$(id -u):$(id -g)" --entrypoint shellcheck -v "$PWD":/s -w /s rhysd/actionlint:latest .github/scripts/native-tool-smoke.sh .github/scripts/verify-packages.sh .github/scripts/wait-for-nuget.sh +docker run --rm --user "$(id -u):$(id -g)" --entrypoint shellcheck -v "$PWD":/s -w /s rhysd/actionlint:latest .github/scripts/*.sh git ls-files -s .github/scripts grep -rnE '\b(dtk|rtk) ' .github CONTRIBUTING.md CLAUDE.md ARCHITECTURE.md git status --short @@ -1869,7 +1917,7 @@ git status --short Expected: - 0 warnings, format clean, and every suite green with `Tests.Smoke.Aot` `Skipped: 30`. - Both linters silent. -- All three scripts show mode `100755`. +- All five scripts show mode `100755`: `native-tool-smoke.sh`, `verify-packages.sh`, `wait-for-nuget.sh`, `set-server-json-version.sh`, and `pointer-package-ids.sh`. - The `grep` prints nothing. - A clean working tree. @@ -1884,7 +1932,7 @@ gh pr create --base develop --title "feat: Native AOT tool packages and release The description must include: - A summary: the two tool projects as RID-specific AOT packages with an `any` fallback, the shared smoke script/action, `pack.yml`, and the new `publish.yml` order. -- The 12 "Deviations from the spec" above. Deviation 1 goes first and cites the Alpine reproduction. +- The 14 "Deviations from the spec" above. Deviation 1 goes first and cites the Alpine reproduction. - The package sizes, and that the release now ships 22 packages. - The PR 2 follow-ups that are included (guard tests, stderr capture, `CreateApp`, split steps, TRX upload), and the one that's obsolete (chmod on extracted binaries). - The maintainer checklist from Step 5 below. diff --git a/src/ProjGraph.Cli/ProjGraph.Cli.csproj b/src/ProjGraph.Cli/ProjGraph.Cli.csproj index 843a015..48ac457 100644 --- a/src/ProjGraph.Cli/ProjGraph.Cli.csproj +++ b/src/ProjGraph.Cli/ProjGraph.Cli.csproj @@ -9,7 +9,9 @@ RID below; `dotnet pack -r ` on a matching OS builds each native package, and `dotnet pack -r any -p:PublishAot=false` builds the framework-dependent fallback. .github/workflows/pack.yml builds them all. Keep linux-musl-* listed with linux-*: without - them, musl systems resolve the glibc package, which can't start there. --> + them, musl systems resolve the glibc package, which can't start there. When changing the RID list, + also update pack.yml's matrix, verify-packages.sh, and publish.yml's verify line (see + ARCHITECTURE.md "Tool Packages"). --> true win-x64;linux-x64;linux-arm64;linux-musl-x64;linux-musl-arm64;osx-arm64;any diff --git a/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj b/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj index 8a87915..c4d4e32 100644 --- a/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj +++ b/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj @@ -12,7 +12,9 @@ RID below; `dotnet pack -r ` on a matching OS builds each native package, and `dotnet pack -r any -p:PublishAot=false` builds the framework-dependent fallback. .github/workflows/pack.yml builds them all. Keep linux-musl-* listed with linux-*: without - them, musl systems resolve the glibc package, which can't start there. --> + them, musl systems resolve the glibc package, which can't start there. When changing the RID list, + also update pack.yml's matrix, verify-packages.sh, and publish.yml's verify line (see + ARCHITECTURE.md "Tool Packages"). --> true win-x64;linux-x64;linux-arm64;linux-musl-x64;linux-musl-arm64;osx-arm64;any From e3af76454885c799f0d3ec18f920e3c066252f9e Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 03:03:50 +0200 Subject: [PATCH 14/15] fix(ci): install the smoke tools on macOS arm64 and Windows dotnet tool install on macOS arm64 downloads microsoft.netcore.app.host.osx-x64 for RID-specific packages (the SDK reads tools/any as a pre-net6 framework), so the smoke NuGet config now lists NuGet.org after the local packages. On Windows the tool path holds .cmd launchers, so the smoke suite runs the executables installed in the tool store. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/native-tool-smoke.sh | 39 ++++++++++++--- ...-09-14-native-aot-pr3-packaging-release.md | 49 +++++++++++++++---- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/.github/scripts/native-tool-smoke.sh b/.github/scripts/native-tool-smoke.sh index 5ec9c49..1a88a22 100755 --- a/.github/scripts/native-tool-smoke.sh +++ b/.github/scripts/native-tool-smoke.sh @@ -28,8 +28,11 @@ dotnet pack src/ProjGraph.Mcp --no-build --configuration Release -p:Version="$ve dotnet pack src/ProjGraph.Cli --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages dotnet pack src/ProjGraph.Mcp --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages -# Only the local packages are sources, so nothing resolves from NuGet.org, and a fresh package folder -# keeps a cached copy of the same version from being installed instead. +# The tools resolve from the local packages: no ProjGraph package with this version is on NuGet.org +# before a release pushes it, and a fresh package folder keeps a cached copy of the same version from +# being installed instead. NuGet.org is listed for the SDK itself: on macOS arm64, `dotnet tool install` +# downloads microsoft.netcore.app.host.osx-x64 for any RID-specific package (its tools/any folder reads +# as a pre-net6 framework), even though a native tool gets a symlink rather than an apphost shim. cat > artifacts/smoke-nuget.config <<'XML' @@ -37,6 +40,7 @@ cat > artifacts/smoke-nuget.config <<'XML' + XML @@ -46,19 +50,42 @@ for tool in Cli Mcp; do --configfile artifacts/smoke-nuget.config --tool-path "artifacts/tools/$tool" done -exe='' +# Prints the one file named $2 inside the package store of the tool path $1, and fails otherwise. +store_executable() { + found='' + for candidate in "$1"/.store/*/*/*/*/tools/*/*/"$2"; do + if [ -f "$candidate" ]; then + if [ -n "$found" ]; then + echo "::error::More than one $2 in $1/.store." >&2 + exit 1 + fi + found=$candidate + fi + done + if [ -z "$found" ]; then + echo "::error::No $2 in $1/.store." >&2 + exit 1 + fi + printf '%s\n' "$found" +} + +cli_native=artifacts/tools/Cli/projgraph +mcp_native=artifacts/tools/Mcp/ProjGraph.Mcp mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll if [ "${RUNNER_OS:-}" = 'Windows' ]; then - exe='.exe' + # On Windows the tool path holds .cmd launchers, not links, so the smoke suite runs the executables + # the packages installed into the tool store. + cli_native=$(store_executable artifacts/tools/Cli ProjGraph.Cli.exe) + mcp_native=$(store_executable artifacts/tools/Mcp ProjGraph.Mcp.exe) # The MCP SDK starts stdio servers through `cmd.exe /c` on Windows, which is fragile with a quoted # dotnet host path. The JIT apphost runs the same build without one. mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.exe fi PROJGRAPH_SMOKE_REQUIRED=true \ -PROJGRAPH_SMOKE_CLI_NATIVE="artifacts/tools/Cli/projgraph$exe" \ +PROJGRAPH_SMOKE_CLI_NATIVE="$cli_native" \ PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ -PROJGRAPH_SMOKE_MCP_NATIVE="artifacts/tools/Mcp/ProjGraph.Mcp$exe" \ +PROJGRAPH_SMOKE_MCP_NATIVE="$mcp_native" \ PROJGRAPH_SMOKE_MCP_REFERENCE="$mcp_reference" \ dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ --logger "console;verbosity=normal" --logger "trx;LogFileName=smoke.trx" \ diff --git a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md index 46252d1..8aa2b55 100644 --- a/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md +++ b/docs/superpowers/plans/2026-09-14-native-aot-pr3-packaging-release.md @@ -42,7 +42,9 @@ A new reusable workflow, `pack.yml`, runs the action for all six native RIDs and 2. **`pack.yml` is a reusable workflow, and it also runs on PRs that change packaging.** The spec puts `pack-native`/`pack-portable` directly in `publish.yml`. As a separate workflow, a PR that touches the tool projects, the smoke suite, the packaging scripts, `Directory.*.props`, or `global.json` builds and smoke-tests every platform before merge. Otherwise the first Windows, macOS, Arm64, or musl native build would happen on a release tag. `publish.yml` calls it with `uses:`, so the release job graph is the spec's `prepare → pack-native ×6 + pack-portable → publish`. 3. **The release smoke installs the tools with `dotnet tool install`** from the pointer package plus the native package, instead of extracting executables from the `.nupkg`. - This exercises the pointer's RID resolution and the exec-bit handling. Extracting would lose the exec bit, so the PR 2 note "chmod +x on extracted executables" doesn't apply. - - The install uses a NuGet config that lists only the local packages and a fresh package folder. A missing native package fails the install (verified) instead of falling back to NuGet.org. + - The install uses a fresh package folder and a NuGet config that lists the local packages first. + - NuGet.org is also listed, because the first PR run showed `dotnet tool install` on macOS arm64 always downloading `microsoft.netcore.app.host.osx-x64` for RID-specific packages. The SDK reads `tools/any` as a pre-net6 framework, even though a native tool only gets a symlink. No ProjGraph package with the CI version (`0.0.0-ci.`) exists on NuGet.org, so the tools still resolve from the local packages. + - On Windows the SDK writes `.cmd` launchers into the tool path, not links. There the smoke suite runs `ProjGraph.Cli.exe` and `ProjGraph.Mcp.exe` from the tool store, which are the shipped executables. This also makes `NativeBuildGuardTests` meaningful on Windows. 4. **`aot-smoke` uses the same pack → install → smoke path** through the shared composite action, instead of `dotnet publish`. The job id stays `aot-smoke`, so the pending "make it required" step still applies. 5. **Native debug symbols aren't packaged.** - The setting is `CopyOutputSymbolsToPublishDirectory=false` when `PublishAot=true`. @@ -893,8 +895,11 @@ dotnet pack src/ProjGraph.Mcp --no-build --configuration Release -p:Version="$ve dotnet pack src/ProjGraph.Cli --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages dotnet pack src/ProjGraph.Mcp --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages -# Only the local packages are sources, so nothing resolves from NuGet.org, and a fresh package folder -# keeps a cached copy of the same version from being installed instead. +# The tools resolve from the local packages: no ProjGraph package with this version is on NuGet.org +# before a release pushes it, and a fresh package folder keeps a cached copy of the same version from +# being installed instead. NuGet.org is listed for the SDK itself: on macOS arm64, `dotnet tool install` +# downloads microsoft.netcore.app.host.osx-x64 for any RID-specific package (its tools/any folder reads +# as a pre-net6 framework), even though a native tool gets a symlink rather than an apphost shim. cat > artifacts/smoke-nuget.config <<'XML' @@ -902,6 +907,7 @@ cat > artifacts/smoke-nuget.config <<'XML' + XML @@ -911,19 +917,42 @@ for tool in Cli Mcp; do --configfile artifacts/smoke-nuget.config --tool-path "artifacts/tools/$tool" done -exe='' +# Prints the one file named $2 inside the package store of the tool path $1, and fails otherwise. +store_executable() { + found='' + for candidate in "$1"/.store/*/*/*/*/tools/*/*/"$2"; do + if [ -f "$candidate" ]; then + if [ -n "$found" ]; then + echo "::error::More than one $2 in $1/.store." >&2 + exit 1 + fi + found=$candidate + fi + done + if [ -z "$found" ]; then + echo "::error::No $2 in $1/.store." >&2 + exit 1 + fi + printf '%s\n' "$found" +} + +cli_native=artifacts/tools/Cli/projgraph +mcp_native=artifacts/tools/Mcp/ProjGraph.Mcp mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll if [ "${RUNNER_OS:-}" = 'Windows' ]; then - exe='.exe' + # On Windows the tool path holds .cmd launchers, not links, so the smoke suite runs the executables + # the packages installed into the tool store. + cli_native=$(store_executable artifacts/tools/Cli ProjGraph.Cli.exe) + mcp_native=$(store_executable artifacts/tools/Mcp ProjGraph.Mcp.exe) # The MCP SDK starts stdio servers through `cmd.exe /c` on Windows, which is fragile with a quoted # dotnet host path. The JIT apphost runs the same build without one. mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.exe fi PROJGRAPH_SMOKE_REQUIRED=true \ -PROJGRAPH_SMOKE_CLI_NATIVE="artifacts/tools/Cli/projgraph$exe" \ +PROJGRAPH_SMOKE_CLI_NATIVE="$cli_native" \ PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \ -PROJGRAPH_SMOKE_MCP_NATIVE="artifacts/tools/Mcp/ProjGraph.Mcp$exe" \ +PROJGRAPH_SMOKE_MCP_NATIVE="$mcp_native" \ PROJGRAPH_SMOKE_MCP_REFERENCE="$mcp_reference" \ dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \ --logger "console;verbosity=normal" --logger "trx;LogFileName=smoke.trx" \ @@ -1349,7 +1378,8 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ inputs.ref }} + # The release tag when called from publish.yml; the pull request's merge commit otherwise. + ref: ${{ inputs.ref || github.sha }} - name: Pack and smoke test the native tools uses: ./.github/actions/native-tool-smoke @@ -1378,7 +1408,8 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ inputs.ref }} + # The release tag when called from publish.yml; the pull request's merge commit otherwise. + ref: ${{ inputs.ref || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v6 From 71d1496a4478e623bcb278eca7b69534c129c1a0 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 03:03:50 +0200 Subject: [PATCH 15/15] ci: check out an explicit ref in pack.yml on pull requests Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pack.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index feaa7dc..4c7f115 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -68,7 +68,8 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ inputs.ref }} + # The release tag when called from publish.yml; the pull request's merge commit otherwise. + ref: ${{ inputs.ref || github.sha }} - name: Pack and smoke test the native tools uses: ./.github/actions/native-tool-smoke @@ -97,7 +98,8 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ inputs.ref }} + # The release tag when called from publish.yml; the pull request's merge commit otherwise. + ref: ${{ inputs.ref || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v6