From 32cb785e6301eca1aa2cad56d68e168f7ffd8909 Mon Sep 17 00:00:00 2001 From: "ptr727-codegen[bot]" <275599072+ptr727-codegen[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:47:36 +0000 Subject: [PATCH 1/3] Update codegen files (#106) This PR updates the codegen files. Co-authored-by: ptr727-codegen[bot] <275599072+ptr727-codegen[bot]@users.noreply.github.com> --- CodeGen/CodeGen.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeGen/CodeGen.cs b/CodeGen/CodeGen.cs index 399969b4..55f7fbb7 100644 --- a/CodeGen/CodeGen.cs +++ b/CodeGen/CodeGen.cs @@ -8,7 +8,7 @@ internal static class CodeGen internal static void Quote() { - const string dateTime = "2026-06-03T23:44:33Z"; + const string dateTime = "2026-06-04T05:46:01Z"; Console.WriteLine($"{dateTime} : {QuoteOfTheDay}"); Log.Logger.Information("Quote of the Day: {DateTime} : {Quote}", dateTime, QuoteOfTheDay); } From 7f327e71082a5de22b782663090f0a5e34478ab0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 4 Jun 2026 07:20:20 -0700 Subject: [PATCH 2/3] Back-port CI/CD fixes discovered during the PlexCleaner port (#107) While porting the two-phase CI/CD pattern into PlexCleaner (ptr727/PlexCleaner#723), several issues were found that also apply to ProjectTemplate (the source of truth). Applying them here so the template and its downstreams converge. ## Correctness - **Pin release-leaf builds to the resolved commit.** `build-nugetlibrary`, `build-pypilibrary`, `build-executable`, `build-docker` were passed the orchestrator's `inputs.ref` (a branch name on publisher runs) and re-resolved their own version, so a commit landing mid-run could build artifacts from a different commit than the release tag (`GitCommitId`) points at. Added `needs: [get-version]` and pinned their `ref` to `needs.get-version.outputs.GitCommitId`. - **`github-release` #408/#412 refinements** (already in NxWitness): check out `GitCommitId` instead of the moving `inputs.ref`; allow `workflow_dispatch` through the skip-if-exists gate; make the skip log message event-aware ("will refresh" on dispatch vs "skipping"). ## Robustness / hygiene - **`build-docker` cache-to `ignore-error=true`** so a transient registry cache export can't fail an otherwise-good publish. - **Publisher concurrency** scoped to real publishes (schedule/dispatch/`PUBLISH_ON_MERGE`); no-op `push` runs get a unique per-run group so they don't queue behind or delay a publish. - **Gate `smoke-build` on `unit-test`** so the Docker image build isn't spent when unit tests are already failing. - **Status badges** filtered to `event=schedule` so they reflect the weekly publisher's health, not green no-op push runs. - **Copilot runbook**: fix the REST issue-comments filter to use `copilot-pull-request-reviewer[bot]` (REST carries the `[bot]` suffix; GraphQL doesn't), and document that the login form differs by API. All workflows pass `actionlint`. Handed to the maintainer for merge. --- .github/copilot-instructions.md | 7 +++-- .github/workflows/build-docker-task.yml | 2 +- .github/workflows/build-release-task.yml | 35 +++++++++++++++++++----- .github/workflows/publish-release.yml | 16 ++++++----- .github/workflows/test-pull-request.yml | 6 +++- README.md | 4 +-- 6 files changed, 50 insertions(+), 20 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 654f87f6..752a7172 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -49,6 +49,8 @@ Use this section for provider-specific mechanics. The expected review loop *cont 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). +> **The reviewer login differs by API — this is intentional, not a typo.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer` — **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]` — **with** the suffix. Each query below uses the correct form for its API; match the API, not a single spelling, when adapting them. + ```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`). @@ -94,9 +96,10 @@ gh pr view --json reviews --jq \ '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ | grep -q "$PR_HEAD" && echo "covered via formal review" -# 2. Issue comment — show the most recent Copilot comment for manual confirmation. +# 2. Issue comment — show the most recent Copilot comment for manual +# confirmation. This is the REST API, so the login carries the `[bot]` suffix. gh api repos///issues//comments --jq \ - '[.[] | select(.user.login=="copilot-pull-request-reviewer")] | last | {created_at, body: .body[:200]}' + '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' ``` Coverage is confirmed when (1) exits 0. For issue comments (path 2), body content is the only reliable signal — `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 9d15ba61..fd36a51e 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -100,7 +100,7 @@ jobs: cache-from: | type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop - cache-to: ${{ inputs.push && 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,ignore-error=true', inputs.branch) || '' }} build-args: | LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }} BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 46239edb..33b5a8dc 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -71,10 +71,15 @@ jobs: build-nugetlibrary: name: Build NuGet library job if: ${{ inputs.enable_nuget }} + needs: [get-version] uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the exact commit get-version resolved (immutable), not the + # possibly-moving branch ref: the publisher passes a branch name, and a + # commit landing mid-run could otherwise build artifacts from a different + # commit than the one the release tag (also GitCommitId) points at. + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} # Conditional push to NuGet.org — never on a smoke build. push: ${{ inputs.nuget && !inputs.smoke }} @@ -86,29 +91,35 @@ jobs: build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} + needs: [get-version] uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the resolved commit (see build-nugetlibrary). + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} build-executable: name: Build executable job if: ${{ inputs.enable_executable }} + needs: [get-version] uses: ./.github/workflows/build-executable-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the resolved commit (see build-nugetlibrary). + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} build-docker: name: Build Docker job if: ${{ inputs.enable_docker }} + needs: [get-version] uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the resolved commit (see build-nugetlibrary). + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} # Conditional push to Docker Hub — never on a smoke build. @@ -125,10 +136,13 @@ jobs: steps: + # Check out the exact built commit (NBGV `GitCommitId`), not the + # possibly-moving `inputs.ref` branch, so the uploaded release files + # match the tag even if the branch advances mid-run. - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.get-version.outputs.GitCommitId }} - name: Download NuGet library build artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -159,7 +173,11 @@ jobs: 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)." + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "Release $TAG already exists; workflow_dispatch will refresh it." + else + echo "Release $TAG already exists; skipping release creation (no-op republish)." + fi else echo "exists=false" >> "$GITHUB_OUTPUT" fi @@ -174,8 +192,11 @@ jobs: # 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. + # Skip the no-op weekly republish when the tag already exists, but always + # allow a manual `workflow_dispatch` through so it can repair/refresh a + # partially-created release for the same tag. - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' }} + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 56254f38..6a3ac3da 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,14 +11,16 @@ 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. +# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) +# share a single GLOBAL, ref-independent group so they serialize: they push +# both branches' shared Docker tags + caches and create GitHub releases for +# both 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. Non-publishing `push` runs (the two-phase default) get a +# unique per-run group so they don't queue behind — or delay — a real publish; +# they only execute the no-op `setup` job and skip everything else. concurrency: - group: ${{ github.workflow }} + group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} # 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/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 95a85031..681b00a1 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -101,7 +101,11 @@ jobs: # docs-only PR) — unit tests still run. smoke-build: name: Smoke build changed targets job - needs: [changes] + # Also gate on unit-test: the smoke build includes a Docker image build, so + # don't spend it when unit tests are already failing. A failed unit-test + # leaves this job skipped (needs unsatisfied) and the aggregator blocks on + # the unit-test failure directly. + needs: [changes, unit-test] if: >- needs.changes.outputs.docker == 'true' || needs.changes.outputs.nuget == 'true' || diff --git a/README.md b/README.md index 43339482..e1816cad 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ Which to pick: two-phase suits projects whose consumers are **pushed** updates ( [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-release.yml?logo=github&label=Docker%20Build +[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&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 @@ -550,7 +550,7 @@ Which to pick: two-phase suits projects whose consumers are **pushed** updates ( [prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&filter=*-g*&label=GitHub%20Pre-Release&logo=github [pypi-link]: https://pypi.org/project/ptr727-projecttemplate-library/ [pypireleaseversion-shield]: https://img.shields.io/pypi/v/ptr727-projecttemplate-library?logo=pypi&label=PyPI%20Release -[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?logo=github&label=Releases%20Build +[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Releases%20Build [releases-link]: https://github.com/ptr727/ProjectTemplate/releases [releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release From 7805cf66c67a53f13f6c5639cb9650936f521d6e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 4 Jun 2026 18:14:18 -0700 Subject: [PATCH 3/3] Decouple GitHub-release assembly from per-target builds (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The `github-release` job assembled release assets by referencing specific build jobs (`build-nugetlibrary`, `build-executable`) by `artifact-id`. That coupling meant a non-.NET downstream had to fork `build-release-task.yml` to attach its own artifact, and a .NET project that subset targets (dropped the executable, kept NuGet) hit an empty-`artifact-id` download for the dropped target. This introduces a convention — a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--` — and makes `github-release` collect them by pattern (`release-asset--*`, `merge-multiple`) instead of naming any build job. ## Why Resolves #108: the release orchestration (tag the built commit, create the release, attach assets) is now reusable verbatim by any downstream — a non-.NET project swaps a leaf task instead of forking the orchestrator. Also fixes the latent subsetting bug for .NET downstreams. ## Changes - `build-nugetlibrary-task.yml` / `build-executable-task.yml`: rename release artifacts to `release-asset--{nugetlibrary,executable}`; drop the now-unused `artifact-id` outputs. - `build-release-task.yml`: `github-release` collects assets via pattern download; `needs` retained for ordering (a gated-off target simply contributes no artifact). - `AGENTS.md` / `README.md`: document the orchestration-vs-build seam (with an output-type customization guide) and the no-op republish guarantee. Docker (Docker Hub) and PyPI (its own publish job) contribute no `release-asset-*` and are unchanged. `actionlint` was not run locally (not installed in this environment); workflows parse as valid YAML. Fixes #108 --- .github/workflows/build-executable-task.yml | 19 ++++++------- .github/workflows/build-nugetlibrary-task.yml | 17 +++++------ .github/workflows/build-release-task.yml | 28 +++++++++++++------ AGENTS.md | 9 ++++++ README.md | 24 +++++++++++++++- 5 files changed, 68 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index 4373869f..1353a915 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -21,10 +21,6 @@ on: required: false type: boolean default: false - outputs: - # Output of the uploaded artifact id - artifact-id: - value: ${{ jobs.upload-build-artifacts.outputs.artifact-id }} jobs: @@ -87,13 +83,11 @@ jobs: # Smoke builds only need the per-runtime compile to succeed (fast PR # feedback) — the zipped, downloadable artifact is a release concern, so - # skip the aggregation entirely on smoke. The `artifact-id` output is then - # empty, which is fine because the GitHub release job never runs on smoke. + # skip the aggregation entirely on smoke. The GitHub release job never runs + # on smoke, so no `release-asset-*` artifact is needed then. upload-build-artifacts: name: Upload matrix build artifacts job if: ${{ !inputs.smoke }} - outputs: - artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} runs-on: ubuntu-latest needs: [ build-executable-matrix ] @@ -109,9 +103,14 @@ jobs: - name: Zip build output step run: 7z a -t7z ${{ runner.temp }}/Console.7z ${{ runner.temp }}/publish/* + # GitHub-release asset, uploaded under the `release-asset--*` + # convention that the `github-release` job in build-release-task.yml + # collects by pattern (it never names this job) — so a derived project + # swaps its release contents by replacing this leaf task, not the + # orchestrator. Branch-suffixed so the publisher's branch matrix can + # build both branches in one run without colliding on the artifact name. - name: Upload build artifacts step - id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: executable-build-${{ inputs.branch }} + name: release-asset-${{ inputs.branch }}-executable path: ${{ runner.temp }}/Console.7z diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index a05aa01e..21445b61 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -19,10 +19,6 @@ on: branch: required: true type: string - outputs: - # Output of the uploaded artifact id - artifact-id: - value: ${{ jobs.build-nugetlibrary.outputs.artifact-id }} jobs: @@ -35,8 +31,6 @@ jobs: build-nugetlibrary: name: Build NuGet library project job runs-on: ubuntu-latest - outputs: - artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} needs: [get-version] steps: @@ -76,11 +70,14 @@ jobs: - name: Zip output step run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - # Branch-suffixed so the publisher's branch matrix can build both - # branches in one run without colliding on the artifact name. + # GitHub-release asset, uploaded under the `release-asset--*` + # convention that the `github-release` job in build-release-task.yml + # collects by pattern (it never names this job) — so a derived project + # swaps its release contents by replacing this leaf task, not the + # orchestrator. Branch-suffixed so the publisher's branch matrix can + # build both branches in one run without colliding on the artifact name. - name: Upload build artifacts step - id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: nugetlibrary-build-${{ inputs.branch }} + name: release-asset-${{ inputs.branch }}-nugetlibrary path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 33b5a8dc..1fc6a541 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -144,16 +144,28 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - - name: Download NuGet library build artifacts step + # Collect every release asset by the `release-asset--*` artifact + # convention rather than naming individual build jobs, so this download + # step never changes when a derived project subsets targets: dropping a + # target (delete its job + this job's `needs` entry, per AGENTS.md + # "Per-target subsetting") just leaves one fewer `release-asset-*` to + # match, and a downstream ships different files by adding/replacing a + # leaf `build-*-task.yml` that uploads under this pattern — without + # editing this step. `merge-multiple` flattens the matched artifacts into + # one `./Publish` dir, exactly as the previous per-target downloads did; + # download-artifact@v7 succeeds (downloads nothing) when the pattern + # matches zero artifacts, so a file-less release (tag + LICENSE/README + # only) still works. + # NOTE: subset releases by *deleting* the target, not by `enable_*: + # false`. `enable_*` is for smoke subsetting only — it skips a build job, + # and because `github-release` `needs` those jobs, a skipped one would + # skip this release job too (GitHub Actions: a skipped `needs` job skips + # its dependents). + - name: Download release asset artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} - path: ./Publish - - - name: Download executable build artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} + pattern: release-asset-${{ inputs.branch }}-* + merge-multiple: true path: ./Publish # The weekly publisher re-runs even when a branch has no new commits, so diff --git a/AGENTS.md b/AGENTS.md index 3ffb096a..fbafb0a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,15 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i - **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") — a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. - **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease — `main` ⇒ Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** — the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. - **Per-target subsetting (derived projects).** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. +- **Orchestration vs. build — the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and meant to be synced verbatim from upstream: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./.github/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). The **build** layer — the `build--task.yml` leaf tasks — is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern and **never names a build job**, so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **verbatim** — that is the part a downstream previously had to fork and rewrite, and no longer does. + - **What a downstream still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do — `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. + - **Map your outputs to the right seam** — pick by where each artifact *goes*, not by language: + - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate → `zip` → upload `release-asset--library`; it deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives — it is *not* a generic file step, it is specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. + - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point) — PyPI contributes **no** `release-asset-*`. + - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. + - *Source-only / no build* (validate + tag + release): you need none of the package/image leaf tasks — only your validation in `test-pull-request.yml`, one `release-asset-*` leaf task for the artifact you attach (or zero, if the release is just a tag), and the verbatim `get-version` + `github-release` + `date-badge` orchestration. + - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag — heavyweight but expected even for a non-.NET downstream; acceptable as-is. +- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`) — all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit — including a CI/workflow-only or docs-only change — advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. ## Pull Request Title and Commit Message Conventions diff --git a/README.md b/README.md index e1816cad..462c8499 100644 --- a/README.md +++ b/README.md @@ -524,7 +524,29 @@ This template ships with a **two-phase model** that decouples merging from publi - **Merges to `main`/`develop` do not publish.** A push only smoke-tested the PR; merging it republishes nothing. - **The weekly schedule + manual dispatch are the sole publishers.** [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) runs every **Monday 02:00 UTC** and on-demand via `workflow_dispatch`, and on either trigger does the **full** build/publish of **both** `main` (Release / `latest` / non-prerelease) and `develop` (Debug / `develop` / prerelease) — GitHub release, NuGet/PyPI uploads, multi-arch Docker tags, platform executables, and a refreshed Docker base image. Trigger a release on demand from the Actions UI when you want one between weekly runs. -This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. +This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. A no-op weekly run (no new commit, so an unchanged `SemVer2`) re-pushes nothing to GitHub Releases / NuGet / PyPI — only Docker re-pushes, to pick up upstream base-image refreshes. + +#### Reusing the Release Pipeline in a Derived Project (Any Language) + +The pipeline is built in two layers so you only customize one of them: + +- **Orchestration (sync verbatim — don't rewrite):** the publish plan and branch matrix in `publish-release.yml`, the version step (`get-version-task.yml`), the date badge (`build-datebadge-task.yml`), and — the key part — the **`github-release` job** that tags the built commit, creates the GitHub Release, and attaches assets. It collects assets by the pattern `release-asset--*` and never names a build job, so it works unchanged no matter what you ship. +- **Build (you own these):** the `build--task.yml` leaf tasks. Each one builds an output and either pushes it to a registry, uploads a `release-asset--` artifact for the GitHub Release, or both. + +**The one rule:** to put a file on the GitHub Release, upload it as an artifact named `release-asset--`. That's the seam — implement it in a leaf task and the rest of the release just works. + +Customize by **where your outputs go**, not by language: + +| What you ship | What to do | Goes to | +| --- | --- | --- | +| **A zip / packaged files / a binary** on the GitHub Release (e.g. a data or asset library) | One leaf task: validate → `zip` → upload `release-asset--library` | GitHub Release asset | +| **A NuGet package** | Keep/adapt `build-nugetlibrary-task` (it `dotnet nuget push`es *and* uploads a `release-asset-*`) | NuGet.org **+** GitHub Release asset | +| **A PyPI package** | Keep/adapt `build-pypilibrary-task` (build + artifact) and the `publish-pypi` job in `publish-release.yml` (OIDC upload) | PyPI only | +| **A Docker image** | Keep/adapt `build-docker-task` (pushes multi-arch tags) | Docker Hub only | +| **A compiled app/CLI** | Keep/adapt `build-executable-task` — note it is specifically `dotnet publish`; replace it wholesale for another toolchain | GitHub Release asset | +| **Just validate + tag a release** (no build output) | Put your checks in `test-pull-request.yml`; attach a `release-asset-*` only if you have a file | GitHub Release (tag, optionally an asset) | + +For each output you **don't** ship, delete its `build--task.yml`, its job + `needs` entry in `build-release-task.yml`, its `test-pull-request.yml` path filter, and (PyPI) the `publish-pypi` job. So `build-release-task.yml` is edited to reflect *your* set of leaf jobs, but its `github-release` logic stays as-is — that's the part you reuse rather than fork. `get-version-task.yml` still installs the .NET SDK to run NBGV (which produces the version/tag) even in a non-.NET repo. See [`AGENTS.md`](./AGENTS.md) "Release Model" for the full seam contract and the no-op-republish guarantee. **Opt in to publish-on-merge.** Set the repository variable `PUBLISH_ON_MERGE` to `true` (Settings → Secrets and variables → Actions → Variables) to restore the legacy **continuous-release** model: every push/merge to `main` publishes `main` and every push to `develop` publishes `develop`, immediately. The weekly + manual publishers still run. Leave the variable unset (or `false`) for the two-phase default. It's a repository variable, not a workflow edit, so pulling template updates never conflicts with your choice.