Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions .github/workflows/build-executable-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 ]

Expand All @@ -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-<branch>-*`
# 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
17 changes: 7 additions & 10 deletions .github/workflows/build-nugetlibrary-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down Expand Up @@ -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-<branch>-*`
# 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
28 changes: 20 additions & 8 deletions .github/workflows/build-release-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,28 @@ jobs:
with:
ref: ${{ needs.get-version.outputs.GitCommitId }}

- name: Download NuGet library build artifacts step
# Collect every release asset by the `release-asset-<branch>-*` artifact
# 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:
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
Comment thread
ptr727 marked this conversation as resolved.

# The weekly publisher re-runs even when a branch has no new commits, so
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<target>-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-<target>-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-<branch>-<target>`.** The `github-release` job collects every `release-asset-<branch>-*` 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-<branch>-<name>`. A data-only repo (e.g. a symbol library) has exactly one such task: validate → `zip` → upload `release-asset-<branch>-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-<branch>` 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

Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<branch>-*` and never names a build job, so it works unchanged no matter what you ship.
- **Build (you own these):** the `build-<target>-task.yml` leaf tasks. Each one builds an output and either pushes it to a registry, uploads a `release-asset-<branch>-<name>` 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-<branch>-<name>`. 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-<branch>-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-<target>-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.

Expand Down