From 17264cc3316951932a164ae30c7fa4416f77f295 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 2 Sep 2026 19:53:20 +0200 Subject: [PATCH 1/3] Publish assembly-differ as a distroless container image + GitHub Action Mirrors curb's container setup (nullean/curb#72): the native-AOT linux-x64 build gets containerized via the .NET SDK's own container support onto a chiseled runtime-deps base, pushed to ghcr.io on every push to master (tagged edge) and on release tags (also latest and the semver). action.yml wraps it as a docker-based GitHub Action so a workflow can diff assemblies with no .NET SDK install. Co-Authored-By: Claude Co-authored-by: Cursor --- .github/workflows/ci.yml | 34 +++++++++++++++++ README.md | 28 ++++++++++++++ action.yml | 24 ++++++++++++ build/scripts/CommandLine.fs | 4 ++ build/scripts/Targets.fs | 44 +++++++++++++++++++++- src/assembly-differ/assembly-differ.csproj | 4 ++ 6 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 683b1ac..60e18bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,21 @@ jobs: name: Pack native-AOT tool for ${{ matrix.rid }} shell: bash + # Container image, linux-x64 only for now — the platform every GitHub-hosted Linux runner + # already matches. No --push here and no registry login on this leg, so this only proves the + # container build itself still works: AOT compiles, the chiseled base image resolves, and the + # SDK's container tooling produces an image in the local Docker daemon. The real push to + # ghcr.io happens once, in the build job below, which does pass --push. + - name: Build container image (no push) + if: matrix.rid == 'linux-x64' + run: ./build.sh publishcontainers -s true + shell: bash + + - name: Report container image size + if: matrix.rid == 'linux-x64' + shell: bash + run: docker images nullean/assembly-differ --format '{{.Tag}}\t{{.Size}}' + - name: Upload per-RID package if: github.event_name == 'push' uses: actions/upload-artifact@v4 @@ -55,6 +70,10 @@ jobs: build: runs-on: ubuntu-latest needs: aot-pack + permissions: + contents: write # create GitHub release + packages: write # push to GitHub Packages and ghcr.io + issues: write # release-notes creates labels for its categories steps: - uses: actions/checkout@v5 with: @@ -97,6 +116,21 @@ jobs: run: | until dotnet nuget push build/output/*.nupkg -k ${{secrets.GITHUB_TOKEN}} --skip-duplicate --no-symbols true; do echo "Retrying"; sleep 1; done; + # ghcr.io/nullean/assembly-differ, matching curb's own tagging: "edge" on every push, plus + # "latest" and the semver when this push is an exact release tag — see + # publishContainers/containerImageTags in Targets.fs for how that split is decided. + - name: Log in to ghcr.io + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish container image + if: github.event_name == 'push' + run: ./build.sh publishcontainers -s true --push + - run: ./build.sh generatereleasenotes -s true name: Generate release notes for tag if: github.event_name == 'push' && startswith(github.ref, 'refs/tags') diff --git a/README.md b/README.md index 5581a92..1efe8d1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,34 @@ On Linux, Windows and macOS/arm64, this resolves to a self-contained native-AOT shared .NET runtime required, and no first-run JIT warmup. Everywhere else, it falls back to a framework-dependent build (requires the .NET runtime the tool targets to already be installed). +## GitHub Action + +```yaml +- uses: nullean/assembly-differ@main + with: + first: "nuget|NEST|6.1.0|net46" + second: "nuget|NEST|6.2.0|net46" + args: --target NEST --format markdown +``` + +Runs `assembly-differ` from a pre-built, distroless container (`ghcr.io/nullean/assembly-differ`) — no +.NET SDK install needed in the workflow. `first` and `second` are the two provider specs (see below); +extra flags pass through verbatim via `args`. Linux runners only (`ubuntu-latest` and similar) — +container actions can't run on Windows or macOS runners. + +## Container image + +`ghcr.io/nullean/assembly-differ` also works as a general-purpose container, outside GitHub Actions — +GitLab CI, a local machine without the .NET SDK, anywhere `docker run` works: + +```sh +docker run --rm -v "$(pwd)":/workspace ghcr.io/nullean/assembly-differ:edge diff "directory|/workspace/old" "directory|/workspace/new" +``` + +Distroless: native-AOT, chiseled `runtime-deps` base, no shell, runs as a non-root user. Tags follow +`assembly-differ`'s own releases — `edge` tracks the latest commit on `master`, `latest` and a semver +tag (e.g. `0.17.0`) follow tagged releases. + ## Run ```bat diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..2dc5cca --- /dev/null +++ b/action.yml @@ -0,0 +1,24 @@ +name: 'assembly-differ' +description: 'Diff assemblies or NuGet packages from different sources and report API changes' +branding: + icon: 'git-pull-request' + color: 'blue' +inputs: + first: + description: 'Old assembly provider, e.g. "nuget|NEST|6.1.0|net46" or "directory|C:\6.1.0"' + required: true + second: + description: 'New assembly provider, same format as "first"' + required: true + args: + description: 'Extra arguments passed through to assembly-differ verbatim, e.g. "--target NEST --format markdown"' + required: false + default: '' +runs: + using: 'docker' + image: 'docker://ghcr.io/nullean/assembly-differ:edge' + args: + - diff + - ${{ inputs.first }} + - ${{ inputs.second }} + - ${{ inputs.args }} diff --git a/build/scripts/CommandLine.fs b/build/scripts/CommandLine.fs index a9672ac..77db63f 100644 --- a/build/scripts/CommandLine.fs +++ b/build/scripts/CommandLine.fs @@ -15,10 +15,12 @@ type Arguments = | [] Release | [] CreateReleaseOnGithub + | [] PublishContainers | [] Publish | [] SingleTarget of bool | [] Token of string + | [] Push with interface IArgParserTemplate with member this.Usage = @@ -30,6 +32,7 @@ with | SingleTarget _ -> "Runs the provided sub command without running their dependencies" | Token _ -> "Token to be used to authenticate with github" + | Push -> "publishcontainers only: push the built image to ghcr.io instead of building it into the local Docker daemon" | PristineCheck | GeneratePackages @@ -37,6 +40,7 @@ with | GenerateReleaseNotes | GenerateApiChanges | CreateReleaseOnGithub + | PublishContainers -> "Undocumented, dependent target" member this.Name = match FSharpValue.GetUnionFields(this, typeof) with diff --git a/build/scripts/Targets.fs b/build/scripts/Targets.fs index 1cec9a4..e11cb4f 100644 --- a/build/scripts/Targets.fs +++ b/build/scripts/Targets.fs @@ -147,7 +147,46 @@ let private createReleaseOnGithub (arguments:ParseResults) = ] @ tokenArgs exec "dotnet" (["release-notes"] @ releaseArgs) |> ignore - + +/// Tags for the container image, mirroring the versioning currentVersion already derives from git: +/// "edge" always (so `ghcr.io/nullean/assembly-differ:edge` is always the latest master build), plus +/// "latest" and the plain semver when this is an exact release tag rather than a canary commit — +/// MinVer's canary suffix always contains a hyphen, a clean tag never does. +let private containerImageTags = + lazy( + let version = currentVersion.Value + if version.Contains("-") then "edge" else sprintf "edge;latest;%s" version + ) + +/// Publishes the CLI's native-AOT build as a container image via the .NET SDK's own container +/// support (`dotnet publish -t:PublishContainer`), the same mechanism used for curb — see +/// https://github.com/nullean/curb/pull/72. linux-x64 only for now; a second RID becomes a second +/// manifest-list platform later with no change to action.yml. +/// +/// Base image is the chiseled/distroless runtime-deps image: no shell, minimal surface, and correct +/// for an AOT binary specifically because there is no managed runtime to host — a plain `runtime` +/// image would carry a CLR this binary never uses. +/// +/// --push is an explicit flag, not inferred from a CI/event-name environment variable: the aot-pack +/// job's linux-x64 leg calls this on every trigger (PR, push, tag) purely to prove the container +/// build itself still works, with no ghcr.io credentials configured there, and an env-based "is this +/// a push?" check would have tried (and failed) to push from that job on every non-PR trigger. Only +/// the build job, which does log in, passes --push. +let private publishContainers (arguments:ParseResults) = + let baseImageTag = "10.0-noble-chiseled" + let registryArgs = + if arguments.Contains Push then ["-p"; "ContainerRegistry=ghcr.io"] else [] + let args = + ["publish"; Paths.RootRelative Paths.ToolProject.FullName; "-c"; "Release"; "-f"; "net10.0"; "-r"; "linux-x64"] + @ ["/t:PublishContainer" + "-p"; "DebugType=none" + "-p"; sprintf "ContainerBaseImage=mcr.microsoft.com/dotnet/runtime-deps:%s" baseImageTag + "-p"; sprintf "ContainerRepository=%s" Paths.Repository + "-p"; sprintf "ContainerImageTags=\"%s\"" containerImageTags.Value + "-p"; "ContainerUser=1001:1001"] + @ registryArgs + exec "dotnet" args |> ignore + let private release (arguments:ParseResults) = printfn "release" let private publish (arguments:ParseResults) = printfn "publish" @@ -179,7 +218,8 @@ let Setup (parsed:ParseResults) (subCommand:Arguments) = <| fun _ -> release parsed step CreateReleaseOnGithub.Name createReleaseOnGithub + step PublishContainers.Name publishContainers cmd Publish.Name (Some [Release.Name]) - (Some [CreateReleaseOnGithub.Name; ]) + (Some [CreateReleaseOnGithub.Name; PublishContainers.Name]) <| fun _ -> publish parsed diff --git a/src/assembly-differ/assembly-differ.csproj b/src/assembly-differ/assembly-differ.csproj index 0963c9c..67ed5c1 100644 --- a/src/assembly-differ/assembly-differ.csproj +++ b/src/assembly-differ/assembly-differ.csproj @@ -22,6 +22,10 @@ The root package's DotnetToolSettings.xml (v2) maps each RID to its per-RID package. Installing requires the .NET 10 SDK or greater. --> linux-x64;linux-arm64;win-x64;win-arm64;osx-arm64;any + + true