From 90712f89d981d9bd34994d3cdd98a37f153868e6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 4 Jun 2026 18:01:47 -0700 Subject: [PATCH 1/2] Decouple GitHub-release assembly from per-target builds The github-release job collected release files by referencing specific build jobs (build-nugetlibrary, build-executable) by artifact-id, so a non-.NET downstream had to fork build-release-task.yml to attach its own artifact, and a .NET project that subset targets hit an empty-id download for the dropped target. Collect release assets by the release-asset--* artifact convention instead: leaf tasks upload under that name and the github-release job downloads them by pattern, never naming a build job. The release orchestration (tag, create release, attach assets) is now reusable verbatim and target subsetting no longer breaks the download. Document the orchestration-vs-build seam and the no-op republish guarantee in AGENTS.md and README.md. Fixes #108 --- .github/workflows/build-executable-task.yml | 19 +++++++-------- .github/workflows/build-nugetlibrary-task.yml | 17 ++++++------- .github/workflows/build-release-task.yml | 19 ++++++++------- AGENTS.md | 9 +++++++ README.md | 24 ++++++++++++++++++- 5 files changed, 59 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index 4373869f..1353a915 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -21,10 +21,6 @@ on: required: false type: boolean default: false - outputs: - # Output of the uploaded artifact id - artifact-id: - value: ${{ jobs.upload-build-artifacts.outputs.artifact-id }} jobs: @@ -87,13 +83,11 @@ jobs: # Smoke builds only need the per-runtime compile to succeed (fast PR # feedback) — the zipped, downloadable artifact is a release concern, so - # skip the aggregation entirely on smoke. The `artifact-id` output is then - # empty, which is fine because the GitHub release job never runs on smoke. + # skip the aggregation entirely on smoke. The GitHub release job never runs + # on smoke, so no `release-asset-*` artifact is needed then. upload-build-artifacts: name: Upload matrix build artifacts job if: ${{ !inputs.smoke }} - outputs: - artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} runs-on: ubuntu-latest needs: [ build-executable-matrix ] @@ -109,9 +103,14 @@ jobs: - name: Zip build output step run: 7z a -t7z ${{ runner.temp }}/Console.7z ${{ runner.temp }}/publish/* + # GitHub-release asset, uploaded under the `release-asset--*` + # convention that the `github-release` job in build-release-task.yml + # collects by pattern (it never names this job) — so a derived project + # swaps its release contents by replacing this leaf task, not the + # orchestrator. Branch-suffixed so the publisher's branch matrix can + # build both branches in one run without colliding on the artifact name. - name: Upload build artifacts step - id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: executable-build-${{ inputs.branch }} + name: release-asset-${{ inputs.branch }}-executable path: ${{ runner.temp }}/Console.7z diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index a05aa01e..21445b61 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -19,10 +19,6 @@ on: branch: required: true type: string - outputs: - # Output of the uploaded artifact id - artifact-id: - value: ${{ jobs.build-nugetlibrary.outputs.artifact-id }} jobs: @@ -35,8 +31,6 @@ jobs: build-nugetlibrary: name: Build NuGet library project job runs-on: ubuntu-latest - outputs: - artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} needs: [get-version] steps: @@ -76,11 +70,14 @@ jobs: - name: Zip output step run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - # Branch-suffixed so the publisher's branch matrix can build both - # branches in one run without colliding on the artifact name. + # GitHub-release asset, uploaded under the `release-asset--*` + # convention that the `github-release` job in build-release-task.yml + # collects by pattern (it never names this job) — so a derived project + # swaps its release contents by replacing this leaf task, not the + # orchestrator. Branch-suffixed so the publisher's branch matrix can + # build both branches in one run without colliding on the artifact name. - name: Upload build artifacts step - id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: nugetlibrary-build-${{ inputs.branch }} + name: release-asset-${{ inputs.branch }}-nugetlibrary path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 33b5a8dc..9b7edb9c 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -144,16 +144,19 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - - name: Download NuGet library build artifacts step + # Collect every release asset by the `release-asset--*` artifact + # convention rather than naming individual build jobs, so this + # orchestration stays target-agnostic: a target that's gated off + # (`enable_*: false`) simply contributes no matching artifact, and a + # downstream attaches different files by adding/replacing a leaf + # `build-*-task.yml` that uploads under this pattern — without editing + # this file. `merge-multiple` flattens the matched artifacts into one + # `./Publish` dir, exactly as the previous per-target downloads did. + - name: Download release asset artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} - path: ./Publish - - - name: Download executable build artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} + pattern: release-asset-${{ inputs.branch }}-* + merge-multiple: true path: ./Publish # The weekly publisher re-runs even when a branch has no new commits, so diff --git a/AGENTS.md b/AGENTS.md index 3ffb096a..fbafb0a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,15 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i - **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") — a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. - **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease — `main` ⇒ Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** — the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. - **Per-target subsetting (derived projects).** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. +- **Orchestration vs. build — the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and meant to be synced verbatim from upstream: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./.github/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). The **build** layer — the `build--task.yml` leaf tasks — is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern and **never names a build job**, so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **verbatim** — that is the part a downstream previously had to fork and rewrite, and no longer does. + - **What a downstream still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do — `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. + - **Map your outputs to the right seam** — pick by where each artifact *goes*, not by language: + - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate → `zip` → upload `release-asset--library`; it deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives — it is *not* a generic file step, it is specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. + - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point) — PyPI contributes **no** `release-asset-*`. + - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. + - *Source-only / no build* (validate + tag + release): you need none of the package/image leaf tasks — only your validation in `test-pull-request.yml`, one `release-asset-*` leaf task for the artifact you attach (or zero, if the release is just a tag), and the verbatim `get-version` + `github-release` + `date-badge` orchestration. + - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag — heavyweight but expected even for a non-.NET downstream; acceptable as-is. +- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`) — all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit — including a CI/workflow-only or docs-only change — advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. ## Pull Request Title and Commit Message Conventions diff --git a/README.md b/README.md index e1816cad..462c8499 100644 --- a/README.md +++ b/README.md @@ -524,7 +524,29 @@ This template ships with a **two-phase model** that decouples merging from publi - **Merges to `main`/`develop` do not publish.** A push only smoke-tested the PR; merging it republishes nothing. - **The weekly schedule + manual dispatch are the sole publishers.** [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) runs every **Monday 02:00 UTC** and on-demand via `workflow_dispatch`, and on either trigger does the **full** build/publish of **both** `main` (Release / `latest` / non-prerelease) and `develop` (Debug / `develop` / prerelease) — GitHub release, NuGet/PyPI uploads, multi-arch Docker tags, platform executables, and a refreshed Docker base image. Trigger a release on demand from the Actions UI when you want one between weekly runs. -This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. +This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. A no-op weekly run (no new commit, so an unchanged `SemVer2`) re-pushes nothing to GitHub Releases / NuGet / PyPI — only Docker re-pushes, to pick up upstream base-image refreshes. + +#### Reusing the Release Pipeline in a Derived Project (Any Language) + +The pipeline is built in two layers so you only customize one of them: + +- **Orchestration (sync verbatim — don't rewrite):** the publish plan and branch matrix in `publish-release.yml`, the version step (`get-version-task.yml`), the date badge (`build-datebadge-task.yml`), and — the key part — the **`github-release` job** that tags the built commit, creates the GitHub Release, and attaches assets. It collects assets by the pattern `release-asset--*` and never names a build job, so it works unchanged no matter what you ship. +- **Build (you own these):** the `build--task.yml` leaf tasks. Each one builds an output and either pushes it to a registry, uploads a `release-asset--` artifact for the GitHub Release, or both. + +**The one rule:** to put a file on the GitHub Release, upload it as an artifact named `release-asset--`. That's the seam — implement it in a leaf task and the rest of the release just works. + +Customize by **where your outputs go**, not by language: + +| What you ship | What to do | Goes to | +| --- | --- | --- | +| **A zip / packaged files / a binary** on the GitHub Release (e.g. a data or asset library) | One leaf task: validate → `zip` → upload `release-asset--library` | GitHub Release asset | +| **A NuGet package** | Keep/adapt `build-nugetlibrary-task` (it `dotnet nuget push`es *and* uploads a `release-asset-*`) | NuGet.org **+** GitHub Release asset | +| **A PyPI package** | Keep/adapt `build-pypilibrary-task` (build + artifact) and the `publish-pypi` job in `publish-release.yml` (OIDC upload) | PyPI only | +| **A Docker image** | Keep/adapt `build-docker-task` (pushes multi-arch tags) | Docker Hub only | +| **A compiled app/CLI** | Keep/adapt `build-executable-task` — note it is specifically `dotnet publish`; replace it wholesale for another toolchain | GitHub Release asset | +| **Just validate + tag a release** (no build output) | Put your checks in `test-pull-request.yml`; attach a `release-asset-*` only if you have a file | GitHub Release (tag, optionally an asset) | + +For each output you **don't** ship, delete its `build--task.yml`, its job + `needs` entry in `build-release-task.yml`, its `test-pull-request.yml` path filter, and (PyPI) the `publish-pypi` job. So `build-release-task.yml` is edited to reflect *your* set of leaf jobs, but its `github-release` logic stays as-is — that's the part you reuse rather than fork. `get-version-task.yml` still installs the .NET SDK to run NBGV (which produces the version/tag) even in a non-.NET repo. See [`AGENTS.md`](./AGENTS.md) "Release Model" for the full seam contract and the no-op-republish guarantee. **Opt in to publish-on-merge.** Set the repository variable `PUBLISH_ON_MERGE` to `true` (Settings → Secrets and variables → Actions → Variables) to restore the legacy **continuous-release** model: every push/merge to `main` publishes `main` and every push to `develop` publishes `develop`, immediately. The weekly + manual publishers still run. Leave the variable unset (or `false`) for the two-phase default. It's a repository variable, not a workflow edit, so pulling template updates never conflicts with your choice. From f7f5b08882aaed86ec73da3ab5bd0458d94f2fe9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 4 Jun 2026 18:11:11 -0700 Subject: [PATCH 2/2] Clarify release subsetting is by deletion, not enable_* gating The download-step comment implied a target gated off via enable_*: false just contributes no artifact, but github-release needs those jobs, so a skipped build job skips the release too. Describe the per-target deletion model and note download-artifact@v7 tolerates a zero-match pattern. --- .github/workflows/build-release-task.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 9b7edb9c..1fc6a541 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -145,13 +145,22 @@ jobs: ref: ${{ needs.get-version.outputs.GitCommitId }} # Collect every release asset by the `release-asset--*` artifact - # convention rather than naming individual build jobs, so this - # orchestration stays target-agnostic: a target that's gated off - # (`enable_*: false`) simply contributes no matching artifact, and a - # downstream attaches different files by adding/replacing a leaf - # `build-*-task.yml` that uploads under this pattern — without editing - # this file. `merge-multiple` flattens the matched artifacts into one - # `./Publish` dir, exactly as the previous per-target downloads did. + # convention rather than naming individual build jobs, so this download + # step never changes when a derived project subsets targets: dropping a + # target (delete its job + this job's `needs` entry, per AGENTS.md + # "Per-target subsetting") just leaves one fewer `release-asset-*` to + # match, and a downstream ships different files by adding/replacing a + # leaf `build-*-task.yml` that uploads under this pattern — without + # editing this step. `merge-multiple` flattens the matched artifacts into + # one `./Publish` dir, exactly as the previous per-target downloads did; + # download-artifact@v7 succeeds (downloads nothing) when the pattern + # matches zero artifacts, so a file-less release (tag + LICENSE/README + # only) still works. + # NOTE: subset releases by *deleting* the target, not by `enable_*: + # false`. `enable_*` is for smoke subsetting only — it skips a build job, + # and because `github-release` `needs` those jobs, a skipped one would + # skip this release job too (GitHub Actions: a skipped `needs` job skips + # its dependents). - name: Download release asset artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: