From f3c55b6cf99652e89503d293293c6c953a62bff0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 14:48:29 -0700 Subject: [PATCH 01/11] Adopt two-phase CI/CD with PR smoke builds and opt-in publish Decouple merging from publishing (issue #97). By default, PRs run path-gated smoke builds (Docker amd64-only, reduced executable matrix, no push) and merges to main/develop no longer publish; the weekly schedule and manual dispatch are the sole publishers, building and releasing both branches in one run via a branch matrix. Set the PUBLISH_ON_MERGE repository variable to true to opt back into legacy publish-on-every-merge. Thread ref/branch/smoke through every reusable build task so branch- derived config keys off inputs.branch (not github.ref_name), letting a scheduled run version and build develop as well as main. Add per-target enable gates and a paths-filter changes job for fast, modular PR feedback; the required status aggregator now fails if path detection fails so a target-changing PR cannot merge with its smoke build skipped. Branch-suffix build artifacts and branch-scope the Docker registry cache so the two matrix legs do not collide or overwrite each other's cache. Move codegen from weekly to daily, fold unit tests into the PR workflow, and remove the now-redundant test-release-task and publish-periodic-docker-release workflows. Correct the Copilot review runbook: the requestReviews GraphQL mutation now reliably re-requests a review (previously needed a manual UI click), and make the wait-for-maintainer-merge gate explicit. --- .github/copilot-instructions.md | 31 ++- .github/workflows/build-datebadge-task.yml | 10 +- .github/workflows/build-docker-task.yml | 49 +++- .github/workflows/build-executable-task.yml | 49 +++- .github/workflows/build-nugetlibrary-task.yml | 21 +- .github/workflows/build-pypilibrary-task.yml | 37 ++- .github/workflows/build-release-task.yml | 83 +++++- .github/workflows/get-version-task.yml | 11 + .../publish-periodic-docker-release.yml | 29 --- .github/workflows/publish-release.yml | 236 +++++++++++------- .../run-periodic-codegen-pull-request.yml | 11 +- .github/workflows/test-pull-request.yml | 181 +++++++++++--- .github/workflows/test-release-task.yml | 44 ---- AGENTS.md | 18 +- ProjectTemplate.slnx | 2 - README.md | 35 +-- 16 files changed, 585 insertions(+), 262 deletions(-) delete mode 100644 .github/workflows/publish-periodic-docker-release.yml delete mode 100644 .github/workflows/test-release-task.yml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7b9e51b0..40436ed0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -47,15 +47,38 @@ Use this section for provider-specific mechanics. The expected review loop *cont ### Triggering and Polling -Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice — treat it as best-effort, not guaranteed. Request review explicitly through the GitHub PR UI (request `Copilot` as a reviewer) after every push. +Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice — treat it as best-effort, not guaranteed. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This now works reliably (it previously did not — a maintainer had to click "re-request review" in the UI; the agent can now drive the loop end-to-end without that hand-off). + +```sh +# 1. PR node id + the Copilot reviewer's bot node id (read from any existing +# Copilot review; the reviewer login is `copilot-pull-request-reviewer`). +PR_NODE=$(gh pr view --json id --jq '.id') +BOT_ID=$(gh api graphql -f query=' +{ + repository(owner: "", name: "") { + pullRequest(number: ) { + reviews(first: 1) { nodes { author { __typename ... on Bot { id } } } } + } + } +}' --jq '.data.repository.pullRequest.reviews.nodes[0].author.id') + +# 2. Re-request a Copilot review on the current head. +gh api graphql -f query=' +mutation($pr: ID!, $bot: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { + pullRequest { id } + } +}' -F pr="$PR_NODE" -F bot="$BOT_ID" +``` + +The bot node id is read from an existing Copilot review, so step 1 needs at least one prior review on the PR — the auto-review-on-open normally supplies the first one. If no Copilot review exists yet and auto-review didn't fire, request `Copilot` once through the GitHub PR UI to seed it, then use the mutation for every subsequent re-request. **Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. -Known non-working request paths (don't rely on them): +Known non-working request paths (don't rely on them — use the `requestReviews` mutation above instead): - `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. - `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. -- GraphQL `requestReviews` rejects Copilot's bot node. ### Verify Review Covered Current Head @@ -81,7 +104,7 @@ Coverage is confirmed when (1) exits 0. For issue comments (path 2), body conten If a review did not run on the current head, retry: 1. Wait briefly and check head-SHA coverage (see above). -1. Request review again via the GitHub PR UI. +1. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"); fall back to the GitHub PR UI only if the mutation no-ops. 1. Retry up to two more times (three total). 1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index 471ec3cc..bdd80670 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -2,6 +2,14 @@ name: Build BYOB date badge task on: workflow_call: + inputs: + # Logical branch this badge run is for. The badge only updates on + # `main`; the publisher passes the branch explicitly so a scheduled + # run building `develop` doesn't try to write the main badge. + branch: + required: false + type: string + default: ${{ github.ref_name }} jobs: @@ -16,7 +24,7 @@ jobs: run: echo "date=$(date)" >> $GITHUB_OUTPUT - name: Build BYOB date badge step - if: ${{ github.ref_name == 'main' }} + if: ${{ inputs.branch == 'main' }} uses: RubbaBoy/BYOB@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 with: name: lastbuild diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index e103c23f..22ceb3c6 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -8,6 +8,26 @@ on: required: false type: boolean default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving config and tags (`main` => Release/`latest`, + # anything else => Debug/`develop`). Defaults to the triggering ref so + # standalone callers keep working; the publisher passes it explicitly + # so a scheduled run can build `develop` as well as `main`. + branch: + required: false + type: string + default: ${{ github.ref_name }} + # Smoke mode: build `linux/amd64` only (no QEMU/arm64), never push, and + # skip the shared registry `cache-to` so PR builds don't pollute the + # release buildcache. Used for fast PR feedback. + smoke: + required: false + type: boolean + default: false jobs: @@ -15,6 +35,8 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} build-docker: name: Build Docker image job @@ -25,8 +47,13 @@ jobs: - name: Checkout step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + # QEMU only exists to emulate arm64. Smoke builds are amd64-only, so + # skip it entirely to save the emulation setup cost. - name: Setup QEMU step + if: ${{ !inputs.smoke }} uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 with: platforms: linux/amd64,linux/arm64 @@ -34,7 +61,7 @@ jobs: - name: Setup Buildx step uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 with: - platforms: linux/amd64,linux/arm64 + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} # Always login to Docker Hub, not just on push, to benefit from # higher rate limits with a Docker subscription for pulls and cache @@ -51,14 +78,24 @@ jobs: push: ${{ inputs.push }} file: ./Docker/Dockerfile tags: | - docker.io/ptr727/projecttemplate:${{ github.ref_name == 'main' && 'latest' || 'develop' }} + docker.io/ptr727/projecttemplate:${{ inputs.branch == 'main' && 'latest' || 'develop' }} docker.io/ptr727/projecttemplate:${{ needs.get-version.outputs.SemVer2 }} - platforms: linux/amd64,linux/arm64 - cache-from: type=registry,ref=docker.io/ptr727/projecttemplate:buildcache - cache-to: type=registry,ref=docker.io/ptr727/projecttemplate:buildcache,mode=max + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + # Branch-scoped registry cache. READ both branches' caches — the + # layers are nearly identical (only BUILD_CONFIGURATION differs), so + # a main build can seed from develop's cache and vice versa — but + # WRITE only this branch's own tag, and only on a real (non-smoke) + # publish. Branch-scoping is what lets the publisher's weekly matrix + # build main and develop concurrently in one run without the two legs + # overwriting a single shared cache (which would destroy hit rates); + # the smoke `cache-to` skip keeps PR builds from polluting it. + cache-from: | + type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main + type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop + cache-to: ${{ inputs.smoke && '' || format('type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-{0},mode=max', inputs.branch) }} build-args: | LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }} - BUILD_CONFIGURATION=${{ github.ref_name == 'main' && 'Release' || 'Debug' }} + BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }} BUILD_VERSION=${{ needs.get-version.outputs.AssemblyVersion }} BUILD_FILE_VERSION=${{ needs.get-version.outputs.AssemblyFileVersion }} BUILD_ASSEMBLY_VERSION=${{ needs.get-version.outputs.AssemblyVersion }} diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index c9724930..e4cf4942 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -2,6 +2,25 @@ name: Build executable task on: workflow_call: + inputs: + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving build configuration (`main` => Release, else + # Debug). Defaults to the triggering ref for standalone callers. + branch: + required: false + type: string + default: ${{ github.ref_name }} + # Smoke mode: build a representative runtime subset (linux-x64 + + # win-x64) instead of the full 7-runtime matrix, and skip the zip / + # artifact aggregation. Used for fast PR feedback. + smoke: + required: false + type: boolean + default: false outputs: # Output of the uploaded artifact id artifact-id: @@ -13,6 +32,8 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} build-executable-matrix: name: Build executable project matrix job @@ -20,10 +41,18 @@ jobs: needs: [get-version] strategy: matrix: - runtime: [ win-x64, linux-x64, linux-musl-x64, linux-arm, linux-arm64, osx-x64, osx-arm64 ] + runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }} steps: + # NOTE: NuGet restore caching is intentionally NOT enabled on the .NET + # jobs (this matrix, build-nugetlibrary-task, and the unit-test job). The + # restore is low-overhead for this template's small dependency set, and + # `setup-dotnet`'s built-in cache requires a `packages.lock.json` that + # Central Package Management (Directory.Packages.props) does not produce + # by default. The Docker layer cache and uv's cache (which carry the + # expensive work) are enabled; revisit .NET restore caching only if the + # dependency graph grows enough to make it worthwhile. - name: Setup .NET SDK step uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: @@ -31,13 +60,15 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Build executable project step run: | dotnet publish ./Console/Console.csproj \ --runtime ${{ matrix.runtime }} \ -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \ - --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ + --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ -property:PublishAot=false \ -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ @@ -45,14 +76,22 @@ jobs: -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} + # Artifact names are suffixed with the branch so the publisher can build + # `main` and `develop` in the same workflow run (a branch matrix) without + # two legs colliding on an identical artifact name. - name: Upload matrix build artifacts step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: publish-${{ matrix.runtime }} + name: publish-${{ inputs.branch }}-${{ matrix.runtime }} path: ${{ runner.temp }}/publish + # 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. 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 @@ -63,7 +102,7 @@ jobs: - name: Download matrix build artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - pattern: publish-* + pattern: publish-${{ inputs.branch }}-* merge-multiple: true path: ${{ runner.temp }}/publish @@ -74,5 +113,5 @@ jobs: id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: executable-build + name: executable-build-${{ inputs.branch }} path: ${{ runner.temp }}/Console.7z diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index cfd39777..7cfccad9 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -8,6 +8,17 @@ on: required: false type: boolean default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving build configuration (`main` => Release, else + # Debug). Defaults to the triggering ref for standalone callers. + branch: + required: false + type: string + default: ${{ github.ref_name }} outputs: # Output of the uploaded artifact id artifact-id: @@ -18,6 +29,8 @@ jobs: get-version: name: Get version information job uses: ./.github/workflows/get-version-task.yml + with: + ref: ${{ inputs.ref }} build-nugetlibrary: name: Build NuGet library project job @@ -35,6 +48,8 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Build NuGet library project step run: | @@ -42,7 +57,7 @@ jobs: dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ - --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ + --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \ @@ -61,9 +76,11 @@ 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. - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: nugetlibrary-build + name: nugetlibrary-build-${{ inputs.branch }} path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 7b92e201..87ad80f9 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -5,12 +5,25 @@ name: Build PyPI library task # Publishing happens directly in `publish-release.yml` so that the # `id-token: write` permission required by Trusted Publishing is granted # at the entry-point job, not propagated through a reusable-workflow -# chain (which would require every caller — including `test-release-task.yml` -# during PR validation — to also grant id-token write, even when no +# chain (which would require every caller — including the PR smoke build in +# `test-pull-request.yml` — to also grant id-token write, even when no # publishing happens). on: workflow_call: + inputs: + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving the PEP 440 version (`develop` => `.dev0` + # prerelease suffix, anything else => plain release). Defaults to the + # triggering ref for standalone callers. + branch: + required: false + type: string + default: ${{ github.ref_name }} outputs: artifact-name: value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} @@ -22,6 +35,8 @@ jobs: get-version: name: Get version information job uses: ./.github/workflows/get-version-task.yml + with: + ref: ${{ inputs.ref }} build-pypilibrary: name: Build PyPI library project job @@ -31,13 +46,15 @@ jobs: run: working-directory: ./PyPiLibrary outputs: - artifact-name: pypilibrary-build + artifact-name: pypilibrary-build-${{ inputs.branch }} artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} steps: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Setup uv step uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 @@ -95,23 +112,24 @@ jobs: # the main release until a new develop # commit lands. This is accepted as a # small, self-healing gap. - # other refs (PR validation via test-release-task, feature + # other branches (PR smoke build via test-pull-request, feature # branches) -> AssemblyFileVersion as-is. These - # refs never publish; we just need a PEP 440 valid + # never publish; we just need a PEP 440 valid # string for `uv build`. - name: Compute PyPI version step id: pypiver run: | set -euo pipefail - if [[ "$GITHUB_REF" == "refs/heads/develop" ]]; then + if [[ "$BRANCH" == "develop" ]]; then version="${AFV}.dev0" else version="$AFV" fi - echo "PyPI version for $GITHUB_REF: $version" + echo "PyPI version for branch $BRANCH: $version" echo "version=$version" >> "$GITHUB_OUTPUT" env: AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }} + BRANCH: ${{ inputs.branch }} # Replace the `__version__` line in `_version.py` (which ships # hardcoded "0.0.0" so local `uv build` works without CI) with the @@ -141,9 +159,12 @@ jobs: - name: Build sdist and wheel step run: uv build + # Branch-suffixed so the publisher's branch matrix can build both + # branches in one run without colliding on the artifact name. The + # publish-pypi job downloads `pypilibrary-build-`. - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: pypilibrary-build + name: pypilibrary-build-${{ inputs.branch }} path: PyPiLibrary/dist/* diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index cfe9b9ae..a18c0196 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -18,6 +18,46 @@ on: required: false type: boolean default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving config / tags / prerelease for every target. + # Defaults to the triggering ref so standalone callers keep working; + # the publisher passes it explicitly so a scheduled run can build both + # `main` and `develop` in one run. + branch: + required: false + type: string + default: ${{ github.ref_name }} + # Smoke mode: reduced, never-published build for fast PR feedback. + # Forwarded to every target; also hard-disables every push below so a + # smoke run can never publish regardless of the publish flags. + smoke: + required: false + type: boolean + default: false + # Per-target presence gates. Default true (build everything). A derived + # project that drops a target deletes its job below and removes it from + # `github-release`'s `needs`; a PR smoke run sets these from the + # paths-filter so only changed targets build. + enable_docker: + required: false + type: boolean + default: true + enable_nuget: + required: false + type: boolean + default: true + enable_pypi: + required: false + type: boolean + default: true + enable_executable: + required: false + type: boolean + default: true jobs: @@ -25,14 +65,19 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} build-nugetlibrary: name: Build NuGet library job + if: ${{ inputs.enable_nuget }} uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: - # Conditional push to NuGet.org - push: ${{ inputs.nuget }} + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + # Conditional push to NuGet.org — never on a smoke build. + push: ${{ inputs.nuget && !inputs.smoke }} # PyPI publishing happens in `publish-release.yml`, not here, so that # `id-token: write` only needs to be granted at the entry-point job. @@ -40,21 +85,34 @@ jobs: # publish-release workflow downloads it by name in a sibling job. build-pypilibrary: name: Build PyPI library job + if: ${{ inputs.enable_pypi }} uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-executable: name: Build executable job + if: ${{ inputs.enable_executable }} uses: ./.github/workflows/build-executable-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} build-docker: name: Build Docker job + if: ${{ inputs.enable_docker }} uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: - # Conditional push to Docker Hub - push: ${{ inputs.dockerhub }} + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} + # Conditional push to Docker Hub — never on a smoke build. + push: ${{ inputs.dockerhub && !inputs.smoke }} github-release: name: Publish GitHub release job @@ -66,6 +124,8 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Download NuGet library build artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -81,17 +141,20 @@ jobs: # `target_commitish` MUST be set explicitly: softprops doesn't pass a # default through, and GitHub's REST API then defaults the new tag to - # the repository's default branch (main). On `push: develop` runs the - # tag would land on main's tip instead of the develop commit that - # built the artifact, leaving "Browse files" and `git checkout ` - # pointing at unrelated code. + # the repository's default branch (main). We pin it to `inputs.branch` + # (the branch this build targets) rather than `github.sha`, because the + # publisher's weekly run is a branch matrix: a `develop` leg triggered + # by a `schedule`/`workflow_dispatch` on `main` has `github.sha` = + # main's tip, so tagging by SHA would land develop's release on main's + # commit. Tagging by branch name lands the tag on that branch's tip — + # the commit that actually built the artifact — on every trigger. - name: Create GitHub release step uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ github.sha }} - prerelease: ${{ github.ref_name != 'main' }} + target_commitish: ${{ inputs.branch }} + prerelease: ${{ inputs.branch != 'main' }} files: | LICENSE README.md diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 24e1a7ca..0a942b4f 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -2,6 +2,16 @@ name: Get version information task on: workflow_call: + inputs: + # Git ref to check out and version. Empty string falls back to the + # caller's default checkout ref (`github.ref`), preserving the + # original behavior. The publisher passes an explicit branch so a + # scheduled run — which always reports `github.ref` as the default + # branch — can still compute NBGV versions for `develop` too. + ref: + required: false + type: string + default: '' outputs: # Version information outputs SemVer2: @@ -34,6 +44,7 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ inputs.ref }} fetch-depth: 0 # `dotnet/nbgv` is intentionally floated on `master` rather than diff --git a/.github/workflows/publish-periodic-docker-release.yml b/.github/workflows/publish-periodic-docker-release.yml deleted file mode 100644 index a264c592..00000000 --- a/.github/workflows/publish-periodic-docker-release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Publish weekly Docker image to Docker Hub action - -on: - workflow_dispatch: - schedule: - # Run weekly on Mondays at 02:00 UTC - - cron: '0 2 * * MON' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - build-docker: - name: Build Docker image job - uses: ./.github/workflows/build-docker-task.yml - secrets: inherit - with: - # Push to registry - push: true - - date-badge: - name: Create BYOB date badge job - needs: [build-docker] - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 1821d717..2d802c08 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,84 +1,152 @@ -name: Publish project release action - -on: - push: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - create-release: - name: Publish project release job - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - permissions: - contents: write - with: - # Push to GitHub and NuGet and Docker Hub - github: true - nuget: true - dockerhub: true - - publish-pypi: - name: Publish PyPI library job - # Runs on pushes to both `main` and `develop`. `build-pypilibrary-task.yml` - # picks the PEP 440 version per branch (`M.N.P.B` release on main, - # `M.N.P.B.dev0` on develop — BuildNumber stays in the release segment - # so develop's release segment grows past main's per commit). Default - # `pip install ` filters `.dev0` and picks the main release; - # `pip install --pre ` includes dev releases and picks develop's - # higher release segment. Matches how NuGet/Docker tag develop builds - # as prerelease via NBGV `SemVer2`. - # The `pypi` GitHub environment's Deployment branch rule - # (Settings → Environments → pypi) restricts uploads to `main` + - # `develop` as defense in depth — see PyPiLibrary/README.md. - needs: [create-release] - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/project/ptr727-projecttemplate-library/ - # When a `permissions:` block is present, every scope not listed - # collapses to `none`. The job needs three things explicitly: - # - `id-token: write` for Trusted Publishing's OIDC exchange - # (pypa/gh-action-pypi-publish swaps the token for a short-lived - # PyPI upload token; no PYPI_API_TOKEN secret involved). - # - `contents: read` so `actions/checkout`-style operations and any - # repo metadata reads continue to work. - # - `actions: read` so `actions/download-artifact` can list and - # fetch the artifact uploaded by the build workflow earlier in - # the same run. - permissions: - id-token: write - contents: read - actions: read - - steps: - - - name: Download PyPI library build artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: pypilibrary-build - path: ./dist - - - name: Publish to PyPI step - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - packages-dir: ./dist - # Skip rather than fail when the version already exists on PyPI. - # The template ships with `__version__ = "0.0.0"` as a placeholder - # — the release-on-every-push model would otherwise re-upload the - # same version and fail the workflow until the adopter wires a - # real version scheme (see PyPiLibrary/README.md). - skip-existing: true - - date-badge: - name: Create BYOB date badge job - needs: [create-release] - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write +name: Publish project release action + +on: + push: + branches: [ main, develop ] + workflow_dispatch: + schedule: + # Weekly full build/publish of both branches on Mondays at 02:00 UTC. + # This is the guaranteed publisher in the default two-phase model: routine + # merges only smoke-test, and this scheduled run republishes everything + # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). + - cron: '0 2 * * MON' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Documented exception to the standard `cancel-in-progress: true` (see + # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can + # leave a partially pushed multi-arch tag set or a half-created GitHub + # release. Queue instead of cancel so each publish runs to completion. + cancel-in-progress: false + +jobs: + + # Decide WHICH branches to publish and WHETHER to publish at all: + # - push -> publish only the pushed branch, and only when the + # `PUBLISH_ON_MERGE` repository variable is `true` + # (opt-in legacy continuous-release). Unset/false => the + # default two-phase model: merges don't publish. + # - schedule -> always publish BOTH branches (the weekly full build). + # - dispatch -> always publish BOTH branches (manual on-demand publish). + setup: + name: Resolve publish plan job + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.plan.outputs.branches }} + publish: ${{ steps.plan.outputs.publish }} + steps: + - name: Compute publish plan step + id: plan + env: + # Repository variable (Settings -> Actions -> Variables). Unset reads + # as empty string, so the default is the two-phase model. + PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} + run: | + set -euo pipefail + case "${{ github.event_name }}" in + push) + branches='["${{ github.ref_name }}"]' + if [[ "${PUBLISH_ON_MERGE:-}" == "true" ]]; then + publish=true + else + publish=false + fi + ;; + *) + # schedule / workflow_dispatch + branches='["main","develop"]' + publish=true + ;; + esac + echo "Event=${{ github.event_name }} branches=$branches publish=$publish" + echo "branches=$branches" >> "$GITHUB_OUTPUT" + echo "publish=$publish" >> "$GITHUB_OUTPUT" + + # Full build + publish of every target for each planned branch. The branch + # matrix lets a single scheduled run publish both `main` (Release/`latest`, + # non-prerelease) and `develop` (Debug/`develop`, prerelease) — each leg + # checks out and versions its own branch via the threaded `ref`/`branch`. + publish: + name: Publish project release job + needs: [setup] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + permissions: + contents: write + with: + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub and NuGet and Docker Hub. + github: true + nuget: true + dockerhub: true + + publish-pypi: + name: Publish PyPI library job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + runs-on: ubuntu-latest + # `build-pypilibrary-task.yml` picks the PEP 440 version per branch + # (`M.N.P.B` release on main, `M.N.P.B.dev0` on develop). Default + # `pip install ` filters `.dev0` and picks the main release; + # `pip install --pre ` includes dev releases. The `pypi` GitHub + # environment's Deployment branch rule restricts uploads to `main` + + # `develop` as defense in depth — see PyPiLibrary/README.md. + environment: + name: pypi + url: https://pypi.org/project/ptr727-projecttemplate-library/ + # When a `permissions:` block is present, every scope not listed collapses + # to `none`. The job needs three things explicitly: + # - `id-token: write` for Trusted Publishing's OIDC exchange + # (pypa/gh-action-pypi-publish swaps the token for a short-lived PyPI + # upload token; no PYPI_API_TOKEN secret involved). + # - `contents: read` for repo metadata reads. + # - `actions: read` so `actions/download-artifact` can fetch the artifact + # uploaded by the build job earlier in the same run. + permissions: + id-token: write + contents: read + actions: read + + steps: + + - name: Download PyPI library build artifacts step + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + # Branch-suffixed name uploaded by build-pypilibrary-task.yml so both + # branch legs in this run stay distinct. + name: pypilibrary-build-${{ matrix.branch }} + path: ./dist + + - name: Publish to PyPI step + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: ./dist + # Skip rather than fail when the version already exists on PyPI — the + # weekly republish re-uploads the same version when nothing changed. + skip-existing: true + + date-badge: + name: Create BYOB date badge job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-datebadge-task.yml + secrets: inherit + permissions: + contents: write + with: + # The badge task self-gates to `main`; the develop leg is a no-op. + branch: ${{ matrix.branch }} diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index 8508744a..447b4178 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -1,10 +1,15 @@ -name: Run weekly CodeGen and Pull Request action +name: Run daily CodeGen and Pull Request action on: workflow_dispatch: schedule: - # Run weekly on Mondays at 02:00 UTC. - - cron: '0 2 * * MON' + # Run daily at 04:00 UTC. Staggered two hours after the weekly publish + # (`publish-release.yml`, Mondays 02:00) so the two don't start together + # on Mondays. Codegen merges are cheap in the default two-phase model + # (they only smoke-test, the weekly publish batches the actual release), + # so running daily keeps both branches' generated content fresh without + # triggering a build per merge. + - cron: '0 4 * * *' concurrency: # Standard AGENTS.md "Concurrency" convention. Scheduled runs always diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 53e1d628..6fc1d05d 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,37 +1,144 @@ -name: Test pull request action - -on: - pull_request: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - test-release: - name: Test release job - uses: ./.github/workflows/test-release-task.yml - secrets: inherit - - # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs - # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 - check-workflow-status: - name: Check pull request workflow status - runs-on: ubuntu-latest - needs: - [ test-release ] - if: always() - steps: - - name: Check workflow results step - run: | - set -euo pipefail - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." - exit 1 - fi - } - exit_on_result "test-release" "${{ needs.test-release.result }}" +name: Test pull request action + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # Detect which delivery targets a PR actually touches so we only smoke-build + # what changed. Build-workflow files are intentionally NOT in any filter: a + # path filter can't tell a logic change in a build workflow from an action- + # version bump, so workflow validation is left to actionlint + the scheduled + # full build (see AGENTS.md). On `workflow_dispatch` (no PR base to diff + # against) every target is forced on so a manual run is a full smoke build. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + outputs: + docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} + nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} + pypi: ${{ github.event_name == 'pull_request' && steps.filter.outputs.pypi || 'true' }} + executable: ${{ github.event_name == 'pull_request' && steps.filter.outputs.executable || 'true' }} + steps: + - name: Filter changed paths step + id: filter + if: ${{ github.event_name == 'pull_request' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + shared: &shared + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'version.json' + - '*.slnx' + docker: + - *shared + - 'Docker/**' + - 'Console/**' + - 'NuGetLibrary/**' + executable: + - *shared + - 'Console/**' + - 'NuGetLibrary/**' + nuget: + - *shared + - 'NuGetLibrary/**' + pypi: + - 'PyPiLibrary/**' + + # Unit tests are cheap and validate the shared C# code, so they always run + # regardless of which targets changed. + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore .NET local tools step + run: dotnet tool restore + + - name: Check formatting with CSharpier step + run: dotnet csharpier check . + + - name: Verify .NET style with dotnet format step + run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + + - name: Run unit tests step + run: dotnet test + + # Fast PR feedback: build only the changed targets, in smoke mode (Docker + # amd64-only, reduced executable matrix, no publishing). Validates the PR's + # base-branch configuration (Release for main, Debug for develop) by passing + # `branch: github.base_ref`. Skipped entirely when no target changed (e.g. a + # docs-only PR) — unit tests still run. + smoke-build: + name: Smoke build changed targets job + needs: [changes] + if: >- + needs.changes.outputs.docker == 'true' || + needs.changes.outputs.nuget == 'true' || + needs.changes.outputs.pypi == 'true' || + needs.changes.outputs.executable == 'true' + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + with: + smoke: true + # Do not publish anything from a PR. + github: false + nuget: false + dockerhub: false + # Check out the PR head; validate it in the base branch's configuration. + # `workflow_dispatch` has empty head_ref/base_ref, so fall back to the + # triggering ref. + ref: ${{ github.head_ref || github.ref_name }} + branch: ${{ github.base_ref || github.ref_name }} + enable_docker: ${{ needs.changes.outputs.docker == 'true' }} + enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} + enable_pypi: ${{ needs.changes.outputs.pypi == 'true' }} + enable_executable: ${{ needs.changes.outputs.executable == 'true' }} + + # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs + # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 + # This job's name is bound to the branch ruleset as the required status check + # context — do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). + check-workflow-status: + name: Check pull request workflow status + runs-on: ubuntu-latest + needs: + [ changes, unit-test, smoke-build ] + if: always() + steps: + - name: Check workflow results step + run: | + set -euo pipefail + exit_on_result() { + if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then + echo "Job '$1' failed or was cancelled." + exit 1 + fi + } + # The paths-filter job MUST succeed: if it failed we don't know which + # targets changed, so a target-changing PR could merge with its smoke + # build silently skipped. Treat anything other than success as a block. + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + exit 1 + fi + # unit-test always runs; smoke-build may be legitimately skipped + # (no target changed) — `skipped` passes, only failure/cancelled blocks. + exit_on_result "unit-test" "${{ needs.unit-test.result }}" + exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml deleted file mode 100644 index bc36044d..00000000 --- a/.github/workflows/test-release-task.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Test release task - -on: - workflow_call: - workflow_dispatch: - -jobs: - - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Restore .NET local tools step - run: dotnet tool restore - - - name: Check formatting with CSharpier step - run: dotnet csharpier check . - - - name: Verify .NET style with dotnet format step - run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - - - name: Run unit tests step - run: dotnet test - - build-release: - name: Build release without publishing job - needs: [unit-test] - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - with: - # Do not publish - github: false - nuget: false - dockerhub: false diff --git a/AGENTS.md b/AGENTS.md index 4f7ffd17..1289ec6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Treat this file as authoritative for everything else; don't restate its rules el ## Branching Model - `develop` is the integration branch. Feature branches → `develop` is **squash-only**; develop is kept linear. -- `develop` → `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which is what makes the "release on every push" model attribute releases to the develop commits that produced them. Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. +- `develop` → `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which lets the release model attribute releases to the develop commits that produced them (relevant both for the weekly publish and the opt-in `PUBLISH_ON_MERGE` mode — see "Release Model" below). Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. - All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. - **`develop` is forward-only — no `main → develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` (`b9b0447`, `410ba56`, `ffb9e64`, `5ce95cf`, etc.) predate this rule and must not be repeated. - **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. @@ -30,6 +30,16 @@ Treat this file as authoritative for everything else; don't restate its rules el - **Dual-target codegen + per-run state = merge conflicts.** If a generator embeds per-invocation state (timestamps, GUIDs, build IDs) and runs independently on `main` and `develop`, the two branches' outputs diverge and every `develop → main` release conflicts on the generated file. This template's `CodeGen/CodeGen.cs` demo embeds a timestamp; the codegen workflow passes `--runtime "${{ github.run_started_at }}"` to both matrix legs so they produce byte-identical output. **That `--runtime` plumbing is template hygiene only — not a codegen pattern derived projects should reproduce.** Your real generators should either be deterministic given the same inputs (preferred), or not run on both release branches simultaneously, or absorb the per-release merge cost. - **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form — do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./README.md#template---github-setup) for the secret-setup procedure. +## Release Model + +The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: + +- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters — a filter can't tell a logic change from an action-version bump, so workflow changes are validated by `actionlint` + the scheduled full build. +- **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. +- **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. + ## Pull Request Title and Commit Message Conventions ### Format @@ -74,14 +84,14 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom ### Expected Review Loop 1. Push changes to the PR branch. -2. Confirm a review was requested for the **current head SHA** (auto-trigger is unreliable; request explicitly). +2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (now reliable end-to-end — see the runbook); the UI is only a fallback. 3. Wait for review activity on that head. 4. Triage findings. 5. Apply fixes or write a rationale for declines. 6. Reply to each thread and resolve what was addressed. 7. Re-run the loop after every fix push until no actionable findings remain. -`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Merge only after review on the latest head SHA is confirmed and actionable findings are closed. +`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Drive the loop to green — review confirmed on the latest head SHA and every actionable finding closed — and then **wait for the maintainer's explicit permission to merge**. The agent does not merge on its own (consistent with "default to staging"; merging is maintainer-authorized). For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract; that file owns the mechanics. @@ -119,7 +129,7 @@ These conventions describe the target state. New and modified workflows must res - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do — `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim — renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exception**: [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order. Cancellation would leave auto-merge in an inconsistent state. The rationale is recorded inline in that workflow's header comment. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order — cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses `cancel-in-progress: false` because cancelling a publish mid-flight can leave a partially pushed multi-arch Docker tag set or a half-created GitHub release; queueing lets each publish run to completion. - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms — `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. diff --git a/ProjectTemplate.slnx b/ProjectTemplate.slnx index e751e769..ceffba5b 100644 --- a/ProjectTemplate.slnx +++ b/ProjectTemplate.slnx @@ -8,12 +8,10 @@ - - diff --git a/README.md b/README.md index bac4755f..43339482 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ Licensed under the [MIT License][license-link]\ - GitHub project security Settings / Secrets / Actions — for the codegen workflow and the codegen merge job. - GitHub project security Settings / Secrets / Dependabot — **required** because Dependabot-triggered `pull_request` workflow runs use a separate, restricted secret context that doesn't see Actions secrets. Without the App secrets in the Dependabot store, the `merge-dependabot` job in `merge-bot-pull-request.yml` can't mint an App token and the PR will never auto-merge. - If the codegen workflows require additional secrets (e.g. third-party API keys), register them in the Actions store; if a Dependabot-triggered workflow ever needs them, register them in the Dependabot store too. - - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly — unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This is why `publish-release.yml` fires on the merge commit after Dependabot or codegen auto-merge, and why the codegen workflow no longer needs the legacy close/reopen dance to trigger auto-merge. + - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly — unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This matters for two reasons: bot-opened PRs trigger the `test-pull-request.yml` smoke build (so they can't auto-merge unvalidated), and — when `PUBLISH_ON_MERGE` is enabled — the merge commit triggers `publish-release.yml`. It also means the codegen workflow no longer needs the legacy close/reopen dance to trigger auto-merge. - The codegen auto-merge condition in `merge-bot-pull-request.yml` (`merge-codegen` job) requires: - **Event is `opened` or `reopened`** — auto-merge is enabled once per PR at open time; subsequent `synchronize` events do not re-enable. This is what lets the `disable-auto-merge-on-maintainer-push` safeguard (below) stick. - `github.event.pull_request.user.login == 'ptr727-codegen[bot]'` — PR was opened by the App. @@ -465,7 +465,7 @@ Licensed under the [MIT License][license-link]\ **Codegen workflow schedule**: -- `run-periodic-codegen-pull-request.yml` runs every **Monday** at 02:00 UTC, plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. +- `run-periodic-codegen-pull-request.yml` runs **daily** at 04:00 UTC (staggered two hours after the weekly publish), plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. Daily is cheap in the default two-phase model — codegen merges only smoke-test; the weekly publish batches the actual release. **GitHub project settings**: @@ -514,32 +514,21 @@ See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the authorita - `develop` → `main`: **merge-commit** (preserves develop's commit list as a real second-parent reference on main; main ruleset enforces this). - **`develop` is forward-only.** No `main → develop` back-merges. The develop squash-only ruleset physically blocks merge commits. - **Bots open parallel PRs against both branches.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates each ecosystem entry per branch, and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix (branch names `codegen-main` and `codegen-develop`). Each branch absorbs its own bot PRs independently — neither falls behind, no back-merges needed. +- **Review-then-merge loop.** Every PR is reviewed by GitHub Copilot. The agent pushes, re-requests a review on the new head (now reliable via the `requestReviews` GraphQL mutation), addresses and resolves each finding, repeats until green, and then **waits for the maintainer's explicit permission to merge** — it does not self-merge. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette) and the [Copilot Review Runbook](./.github/copilot-instructions.md#github-copilot-review-runbook) for the mechanics. -### Template - Release Distribution Model: Push vs. Pull +### Template - Release Distribution Model: Two-Phase by Default -This template ships with a **push-on-merge release model** — every commit on `main` triggers [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) which publishes a GitHub release, NuGet/PyPI uploads, Docker tags, and platform executables. With the dual-target bot model (Dependabot/codegen targeting both branches), this means every Dependabot bump that lands on `main` produces a new release. That's the right default for projects whose consumers **pull** at their own cadence (Docker pulls, NuGet/PyPI installs, manual binary downloads) — releases are cheap and frequent, consumers update on their own schedule. +This template ships with a **two-phase model** that decouples merging from publishing: -For projects whose consumers are **pushed** updates (HACS for Home Assistant, package managers that auto-update integrations, Linux distros that vendor from `main`), every release is a forced update to all users. Frequent bot-driven releases become noise. To switch to a **manual main-release model** while keeping the rest of the dual-target dual-channel flow: +- **Pull requests smoke-test only.** [`.github/workflows/test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then path-gates a **reduced** build of only the targets a PR touches (`dorny/paths-filter`): Docker as `linux/amd64` only (no QEMU/arm64), the executable as a representative runtime subset, and nothing is pushed. A docs-only PR runs unit tests alone; a Dependabot github-actions bump is unit-tests-only. This is fast feedback, not a release. +- **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. -1. Edit [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) and change the trigger: +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. - ```diff - on: - - push: - - branches: [ main, develop ] - - workflow_dispatch: - + push: - + branches: [ develop ] - + workflow_dispatch: - ``` - - Result: `develop` pushes still publish dev releases automatically (PEP 440 `.dev0` to PyPI, NBGV-prerelease tags on NuGet, prerelease GitHub releases). `main` pushes no longer auto-publish; you trigger the release manually via the GitHub Actions UI (`workflow_dispatch`) when a real release is wanted. - -2. **(Optional)** narrow what flows into `main` automatically. If a sea of Dependabot PRs on `main` is noisy without auto-release, either: - - Drop the `main`-target Dependabot entries from `.github/dependabot.yml` (so deps update on `develop` only, and reach `main` through the next develop → main release the maintainer triggers — closer to a pure develop-only flow with manual cadence), or - - Keep dual-target Dependabot and let the merge-bot auto-merge them silently into `main`; main always has fresh code, but ships only when the maintainer dispatches a release. +**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. -For an example of the manual-release model in production, see [homeassistant-purpleair](https://github.com/ptr727/homeassistant-purpleair) — that integration ships through HACS (push distribution) and uses `workflow_dispatch` for actual releases. +Which to pick: two-phase suits projects whose consumers are **pushed** updates (HACS for Home Assistant, package managers that auto-update, Linux distros that vendor from `main`) where every release is a forced update and frequent bot-driven releases are noise. `PUBLISH_ON_MERGE=true` suits projects whose consumers **pull** at their own cadence (Docker pulls, NuGet/PyPI installs, manual downloads) and want every merged change available immediately. For an example of a push-distribution project, see [homeassistant-purpleair](https://github.com/ptr727/homeassistant-purpleair) (ships through HACS). @@ -547,7 +536,7 @@ For an example of the manual-release model in production, see [homeassistant-pur [commits-link]: https://github.com/ptr727/ProjectTemplate/commits/main [discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions [docker-link]: https://hub.docker.com/r/ptr727/projecttemplate -[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-periodic-docker-release.yml?logo=github&label=Docker%20Build +[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?logo=github&label=Docker%20Build [dockerdevelopversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/develop?label=Docker%20Develop&logo=docker&color=orange [dockerlatestversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/latest?label=Docker%20Latest&logo=docker [github-link]: https://github.com/ptr727/ProjectTemplate From 6bf5257afcc9f717298c04c2a19ae70daf17f807 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 14:50:15 -0700 Subject: [PATCH 02/11] Grant pull-requests: read to the paths-filter job --- .github/workflows/test-pull-request.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 6fc1d05d..6c983f17 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -20,6 +20,12 @@ jobs: changes: name: Detect changed targets job runs-on: ubuntu-latest + # `dorny/paths-filter` lists the PR's changed files via the GitHub API + # (this job does not check out the tree), which needs `pull-requests: read`. + # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. + permissions: + contents: read + pull-requests: read outputs: docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} From 9073b9bca32ebce9df229d520bf967590f323280 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:01:54 -0700 Subject: [PATCH 03/11] Make branch input required and check out PR head by SHA --- .github/workflows/build-datebadge-task.yml | 6 +++--- .github/workflows/build-docker-task.yml | 10 +++++----- .github/workflows/build-executable-task.yml | 6 +++--- .github/workflows/build-nugetlibrary-task.yml | 6 +++--- .github/workflows/build-pypilibrary-task.yml | 8 ++++---- .github/workflows/build-release-task.yml | 10 +++++----- .github/workflows/test-pull-request.yml | 9 ++++++--- 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index bdd80670..3e805f44 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -5,11 +5,11 @@ on: inputs: # Logical branch this badge run is for. The badge only updates on # `main`; the publisher passes the branch explicitly so a scheduled - # run building `develop` doesn't try to write the main badge. + # run building `develop` doesn't try to write the main badge. Required + # (no `github.ref_name` fallback) so the gate can't silently misfire. branch: - required: false + required: true type: string - default: ${{ github.ref_name }} jobs: diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 22ceb3c6..73e33b91 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -14,13 +14,13 @@ on: type: string default: '' # Logical branch driving config and tags (`main` => Release/`latest`, - # anything else => Debug/`develop`). Defaults to the triggering ref so - # standalone callers keep working; the publisher passes it explicitly - # so a scheduled run can build `develop` as well as `main`. + # anything else => Debug/`develop`). Required (no `github.ref_name` + # fallback): the publisher builds develop from a run whose + # `github.ref_name` is `main`, so a silent fallback would mistag it. + # The orchestrator always passes it explicitly. branch: - required: false + required: true type: string - default: ${{ github.ref_name }} # Smoke mode: build `linux/amd64` only (no QEMU/arm64), never push, and # skip the shared registry `cache-to` so PR builds don't pollute the # release buildcache. Used for fast PR feedback. diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index e4cf4942..4373869f 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -9,11 +9,11 @@ on: type: string default: '' # Logical branch driving build configuration (`main` => Release, else - # Debug). Defaults to the triggering ref for standalone callers. + # Debug). Required (no `github.ref_name` fallback, which would mislabel + # the develop leg of the publisher's matrix); the orchestrator passes it. branch: - required: false + required: true type: string - default: ${{ github.ref_name }} # Smoke mode: build a representative runtime subset (linux-x64 + # win-x64) instead of the full 7-runtime matrix, and skip the zip / # artifact aggregation. Used for fast PR feedback. diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index 7cfccad9..a05aa01e 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -14,11 +14,11 @@ on: type: string default: '' # Logical branch driving build configuration (`main` => Release, else - # Debug). Defaults to the triggering ref for standalone callers. + # Debug). Required (no `github.ref_name` fallback, which would mislabel + # the develop leg of the publisher's matrix); the orchestrator passes it. branch: - required: false + required: true type: string - default: ${{ github.ref_name }} outputs: # Output of the uploaded artifact id artifact-id: diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 87ad80f9..d4f209be 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -18,12 +18,12 @@ on: type: string default: '' # Logical branch driving the PEP 440 version (`develop` => `.dev0` - # prerelease suffix, anything else => plain release). Defaults to the - # triggering ref for standalone callers. + # prerelease suffix, anything else => plain release). Required (no + # `github.ref_name` fallback, which would mislabel the develop leg of + # the publisher's matrix); the orchestrator passes it. branch: - required: false + required: true type: string - default: ${{ github.ref_name }} outputs: artifact-name: value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index a18c0196..ce169ec7 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -24,13 +24,13 @@ on: type: string default: '' # Logical branch driving config / tags / prerelease for every target. - # Defaults to the triggering ref so standalone callers keep working; - # the publisher passes it explicitly so a scheduled run can build both - # `main` and `develop` in one run. + # Required (no `github.ref_name` fallback): the publisher builds both + # `main` and `develop` from one run whose `github.ref_name` is `main`, + # so a silent fallback would mislabel the develop leg. Every caller + # passes it explicitly; a missing value should fail loudly. branch: - required: false + required: true type: string - default: ${{ github.ref_name }} # Smoke mode: reduced, never-published build for fast PR feedback. # Forwarded to every target; also hard-disables every push below so a # smoke run can never publish regardless of the publish flags. diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 6c983f17..d7bb077b 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -107,10 +107,13 @@ jobs: github: false nuget: false dockerhub: false - # Check out the PR head; validate it in the base branch's configuration. - # `workflow_dispatch` has empty head_ref/base_ref, so fall back to the + # Check out the PR head by SHA (not head_ref): the head SHA is reachable + # in the base repo via refs/pull/N/head even for fork PRs, whereas the + # head_ref branch name does not exist in the base repo for forks and + # would fail checkout. Validate it in the base branch's configuration. + # `workflow_dispatch` has no pull_request payload, so fall back to the # triggering ref. - ref: ${{ github.head_ref || github.ref_name }} + ref: ${{ github.event.pull_request.head.sha || github.ref_name }} branch: ${{ github.base_ref || github.ref_name }} enable_docker: ${{ needs.changes.outputs.docker == 'true' }} enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} From 7c71225d468778ff37a87b32ccd288165b8f15fc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:09:12 -0700 Subject: [PATCH 04/11] Gate docker cache-to on push and use a global publish concurrency group --- .github/workflows/build-docker-task.yml | 14 ++++++++------ .github/workflows/publish-release.yml | 8 +++++++- AGENTS.md | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 73e33b91..96fe7602 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -84,15 +84,17 @@ jobs: # Branch-scoped registry cache. READ both branches' caches — the # layers are nearly identical (only BUILD_CONFIGURATION differs), so # a main build can seed from develop's cache and vice versa — but - # WRITE only this branch's own tag, and only on a real (non-smoke) - # publish. Branch-scoping is what lets the publisher's weekly matrix - # build main and develop concurrently in one run without the two legs - # overwriting a single shared cache (which would destroy hit rates); - # the smoke `cache-to` skip keeps PR builds from polluting it. + # WRITE only this branch's own tag, and only when actually pushing. + # Gating the export on `inputs.push` (not just `!smoke`) means a + # non-publishing build never writes the shared registry cache or + # needs Docker Hub write creds — smoke builds (always push=false) are + # covered too. Branch-scoping is what lets the publisher's weekly + # matrix build main and develop concurrently in one run without the + # two legs overwriting a single shared cache (destroying hit rates). cache-from: | type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop - cache-to: ${{ inputs.smoke && '' || format('type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-{0},mode=max', inputs.branch) }} + cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-{0},mode=max', inputs.branch) || '' }} build-args: | LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }} BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 2d802c08..56254f38 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,8 +11,14 @@ on: # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). - cron: '0 2 * * MON' +# Single GLOBAL group (not ref-scoped): this workflow publishes shared, +# ref-independent artifacts — it pushes both branches' Docker tags + caches +# and creates GitHub releases for both — on schedule/dispatch regardless of +# the triggering ref. A ref-scoped group would let a scheduled run (ref=main) +# and a manual dispatch (ref=develop) run concurrently and double-push. The +# global group serializes every publish run. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }} # Documented exception to the standard `cancel-in-progress: true` (see # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can # leave a partially pushed multi-arch tag set or a half-created GitHub diff --git a/AGENTS.md b/AGENTS.md index 1289ec6e..81802352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ These conventions describe the target state. New and modified workflows must res - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do — `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim — renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order — cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses `cancel-in-progress: false` because cancelling a publish mid-flight can leave a partially pushed multi-arch Docker tag set or a half-created GitHub release; queueing lets each publish run to completion. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order — cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms — `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. From a71e0099ed52914a64a8457af427039d54b0a643 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:16:04 -0700 Subject: [PATCH 05/11] Gate github-release on non-smoke and harden Copilot BOT_ID lookup --- .github/copilot-instructions.md | 6 ++++-- .github/workflows/build-release-task.yml | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 40436ed0..654f87f6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -57,10 +57,12 @@ BOT_ID=$(gh api graphql -f query=' { repository(owner: "", name: "") { pullRequest(number: ) { - reviews(first: 1) { nodes { author { __typename ... on Bot { id } } } } + reviews(first: 50) { nodes { author { __typename login ... on Bot { id } } } } } } -}' --jq '.data.repository.pullRequest.reviews.nodes[0].author.id') +}' --jq '[.data.repository.pullRequest.reviews.nodes[] + | select(.author.login == "copilot-pull-request-reviewer") + | .author.id] | first') # 2. Re-request a Copilot review on the current head. gh api graphql -f query=' diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index ce169ec7..41ea609a 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -116,7 +116,10 @@ jobs: github-release: name: Publish GitHub release job - if: ${{ inputs.github }} + # `&& !inputs.smoke` enforces the "smoke never publishes" guarantee at the + # job level too (matching the `&& !inputs.smoke` push gates above), so a + # smoke caller that also set `github: true` still can't create a release. + if: ${{ inputs.github && !inputs.smoke }} runs-on: ubuntu-latest needs: [get-version, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] From 5eeb7e3414c198969f445560209f851005ea83c4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:28:08 -0700 Subject: [PATCH 06/11] Document always-login as a conscious deviation from gate-on-push --- .github/workflows/build-docker-task.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 96fe7602..c3988c1e 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -63,8 +63,14 @@ jobs: with: platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Always login to Docker Hub, not just on push, to benefit from - # higher rate limits with a Docker subscription for pulls and cache + # Always login to Docker Hub, not just on push, to benefit from higher + # rate limits with a Docker subscription for pulls and cache reads on + # every build (including smoke). This is a CONSCIOUS deviation from the + # NxWitness gate-login-on-push pattern: the trade-off is that fork PRs + # without access to the Docker Hub secrets cannot run the Docker smoke + # build. Acceptable here because the repo is private and PRs are + # same-repo; a public derived project that accepts fork PRs may prefer + # to gate this step on `inputs.push`. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: From c930b0b7352c173899a7a8017de0193510467f42 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:29:33 -0700 Subject: [PATCH 07/11] Drop project-specific name from docker login comment --- .github/workflows/build-docker-task.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index c3988c1e..9d15ba61 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -65,12 +65,12 @@ jobs: # Always login to Docker Hub, not just on push, to benefit from higher # rate limits with a Docker subscription for pulls and cache reads on - # every build (including smoke). This is a CONSCIOUS deviation from the - # NxWitness gate-login-on-push pattern: the trade-off is that fork PRs - # without access to the Docker Hub secrets cannot run the Docker smoke - # build. Acceptable here because the repo is private and PRs are - # same-repo; a public derived project that accepts fork PRs may prefer - # to gate this step on `inputs.push`. + # every build (including smoke). This is a CONSCIOUS choice over gating + # login on `inputs.push`: the trade-off is that fork PRs without access + # to the Docker Hub secrets cannot run the Docker smoke build. + # Acceptable here because the repo is private and PRs are same-repo; a + # public derived project that accepts fork PRs may prefer to gate this + # step on `inputs.push`. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: From f8e3c892c4ec35888234801a8d5579e68f1e2e8c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:34:33 -0700 Subject: [PATCH 08/11] Trigger PyPI smoke build on version.json changes --- .github/workflows/test-pull-request.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index d7bb077b..9e7626e9 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -57,6 +57,11 @@ jobs: - 'NuGetLibrary/**' pypi: - 'PyPiLibrary/**' + # The PyPI package version comes from NBGV reading version.json, + # so a version bump should rebuild it. The other shared files + # (Directory.*.props, *.slnx) are .NET-only and don't affect the + # Python build, so they're intentionally excluded here. + - 'version.json' # Unit tests are cheap and validate the shared C# code, so they always run # regardless of which targets changed. From 8e5e051d739522c902429faf3773005a17be4b45 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:41:53 -0700 Subject: [PATCH 09/11] Skip GitHub release creation when the version tag already exists --- .github/workflows/build-release-task.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 41ea609a..26986fea 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -142,6 +142,28 @@ jobs: artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} path: ./Publish + # The weekly publisher re-runs even when a branch has no new commits, so + # NBGV can produce a SemVer2 that was already released. GitHub release + # creation has no built-in skip-duplicate (unlike NuGet's + # `--skip-duplicate` and PyPI's `skip-existing`), and re-publishing an + # unchanged version is exactly the churn the two-phase model avoids — so + # skip the release step when a release for this tag already exists. The + # Docker mutable tags (`latest`/`develop`) and base-image refresh still + # happen regardless, so security-only rebuilds still ship. + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Release $TAG already exists; skipping release creation (no-op republish)." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + # `target_commitish` MUST be set explicitly: softprops doesn't pass a # default through, and GitHub's REST API then defaults the new tag to # the repository's default branch (main). We pin it to `inputs.branch` @@ -152,6 +174,7 @@ jobs: # commit. Tagging by branch name lands the tag on that branch's tip — # the commit that actually built the artifact — on every trigger. - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true From 55c26fcd749137117d8cd5e2c2f541f466c08f32 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:47:59 -0700 Subject: [PATCH 10/11] Pin release tag to NBGV GitCommitId instead of a moving branch ref --- .github/workflows/build-release-task.yml | 17 +++++++++-------- .github/workflows/get-version-task.yml | 6 ++++++ AGENTS.md | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 26986fea..46239edb 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -166,20 +166,21 @@ jobs: # `target_commitish` MUST be set explicitly: softprops doesn't pass a # default through, and GitHub's REST API then defaults the new tag to - # the repository's default branch (main). We pin it to `inputs.branch` - # (the branch this build targets) rather than `github.sha`, because the - # publisher's weekly run is a branch matrix: a `develop` leg triggered - # by a `schedule`/`workflow_dispatch` on `main` has `github.sha` = - # main's tip, so tagging by SHA would land develop's release on main's - # commit. Tagging by branch name lands the tag on that branch's tip — - # the commit that actually built the artifact — on every trigger. + # the repository's default branch (main). We pin it to NBGV's + # `GitCommitId` — the exact commit the version was computed from. This + # avoids two bugs: `github.sha` would be wrong (the publisher's branch + # matrix builds `develop` from a run whose `github.sha` is main's tip), + # and `inputs.branch` would be a moving ref (a commit landing mid-run + # could tag the release on a newer commit than the one that was built). + # The exact SHA is immutable, on the right branch, and consistent with + # both the SemVer2 tag and the uploaded artifacts. - name: Create GitHub release step if: ${{ steps.release-exists.outputs.exists == 'false' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ inputs.branch }} + target_commitish: ${{ needs.get-version.outputs.GitCommitId }} prerelease: ${{ inputs.branch != 'main' }} files: | LICENSE diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 0a942b4f..e64bd474 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -22,6 +22,11 @@ on: value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} + # Full SHA of the commit NBGV computed the version from. Used to pin the + # GitHub release tag to the exact built commit (immutable), rather than a + # moving branch ref. + GitCommitId: + value: ${{ jobs.get-version.outputs.GitCommitId }} jobs: @@ -33,6 +38,7 @@ jobs: AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} + GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} steps: diff --git a/AGENTS.md b/AGENTS.md index 81802352..f3e60fa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,7 @@ These conventions describe the target state. New and modified workflows must res - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms — `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies — `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish: ${{ github.sha }}` explicitly. Without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. +- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly — without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix — a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). ## Devcontainer From 5f9194e9ca378bab948d260295592a4f8a32340f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 3 Jun 2026 15:55:32 -0700 Subject: [PATCH 11/11] Correct workflow-validation docs: no CI actionlint, lint locally --- .github/workflows/test-pull-request.yml | 9 ++++++--- AGENTS.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 9e7626e9..95a85031 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -14,9 +14,12 @@ jobs: # Detect which delivery targets a PR actually touches so we only smoke-build # what changed. Build-workflow files are intentionally NOT in any filter: a # path filter can't tell a logic change in a build workflow from an action- - # version bump, so workflow validation is left to actionlint + the scheduled - # full build (see AGENTS.md). On `workflow_dispatch` (no PR base to diff - # against) every target is forced on so a manual run is a full smoke build. + # version bump. A workflow-only change is therefore not smoke-built — the + # reusable workflows are exercised instead by the next run that uses them (a + # later code PR's smoke build, or the scheduled/publish run); lint workflow + # edits with `actionlint` locally before pushing (there is no CI lint job). + # On `workflow_dispatch` (no PR base to diff against) every target is forced + # on so a manual run is a full smoke build. changes: name: Detect changed targets job runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index f3e60fa4..5098197b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Treat this file as authoritative for everything else; don't restate its rules el The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: -- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters — a filter can't tell a logic change from an action-version bump, so workflow changes are validated by `actionlint` + the scheduled full build. +- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters — a filter can't tell a logic change from an action-version bump — so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. - **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. - **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.