From 2a4dca83a2f18cfea5663bae96cec9fb4a943aca Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 11 Jul 2026 21:44:46 -0700 Subject: [PATCH 1/3] catalog: add publish-plan-task.yml single-source release-gate decision (#274) Canonical reusable workflow encoding the publish-plan policy (human merge never auto-publishes). --- catalog/snippets/workflows/README.md | 1 + .../snippets/workflows/publish-plan-task.yml | 84 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 catalog/snippets/workflows/publish-plan-task.yml diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index 49265ae9..a2e22f1c 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -6,6 +6,7 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are ** | --- | --- | --- | | `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | | `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | +| `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 | | `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough | | `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough | | `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough | diff --git a/catalog/snippets/workflows/publish-plan-task.yml b/catalog/snippets/workflows/publish-plan-task.yml new file mode 100644 index 00000000..ab7dbbb9 --- /dev/null +++ b/catalog/snippets/workflows/publish-plan-task.yml @@ -0,0 +1,84 @@ +name: Publish plan task + +# Single source of truth for the release-gate decision, reused by every publish-release.yml job so the policy +# lives here, not scattered across job `if:` conditions. A human PR merge never auto-publishes; a release is a +# deliberate dispatch, a bot (Dependabot/codegen) code-merge to main, or the Docker weekly schedule. +# +# Outputs: +# publish - 'true' when this run should publish: a bot-authored push (the codegen App merges every bot PR, so +# its identity - or dependabot[bot] - is the gate), a schedule, or a workflow_dispatch of main/develop. +# A human push (a merge/promotion to main) or a dispatch from any other branch is 'false'. +# stable - 'true' when the target branch is main (stable channel); main-only jobs gate on publish && stable. +# Both outputs are the strings 'true'/'false' - gate with == 'true'; a bare `if: ${{ needs.plan.outputs.publish }}` +# is always truthy (a non-empty string is truthy in an Actions expression). +# +# Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`; a +# Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. A case a given +# caller never triggers (e.g. schedule for a library) is simply inert for it - expected of a single-source task. + +on: + workflow_call: + inputs: + event_name: + description: The triggering event (github.event_name). + required: true + type: string + actor: + description: The actor that triggered the run (github.actor). + required: true + type: string + ref_name: + description: The short ref name (github.ref_name). + required: true + type: string + outputs: + publish: + description: "'true' when this run should publish." + value: ${{ jobs.plan.outputs.publish }} + stable: + description: "'true' when the target branch is main (stable channel)." + value: ${{ jobs.plan.outputs.stable }} + +jobs: + + plan: + name: Plan release job + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.decide.outputs.publish }} + stable: ${{ steps.decide.outputs.stable }} + + steps: + + - name: Decide release plan step + id: decide + env: + EVENT: ${{ inputs.event_name }} + ACTOR: ${{ inputs.actor }} + REF: ${{ inputs.ref_name }} + run: | + set -euo pipefail + publish=false + case "$EVENT" in + workflow_dispatch) + # A human release: only the long-lived branches publish (a stray feature-branch dispatch is a no-op). + [[ "$REF" == "main" || "$REF" == "develop" ]] && publish=true + ;; + schedule) + # Docker weekly refresh (main-only by schedule config). + publish=true + ;; + push) + # A human merge never auto-publishes; only a bot merge to main does. The codegen App merges every + # Dependabot/codegen PR, so github.actor is its identity (dependabot[bot] allowed defensively). The + # ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. + if [[ "$REF" == "main" ]] && { [[ "$ACTOR" == "ptr727-codegen[bot]" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then + publish=true + fi + ;; + esac + stable=false + [[ "$REF" == "main" ]] && stable=true + echo "publish=$publish" >> "$GITHUB_OUTPUT" + echo "stable=$stable" >> "$GITHUB_OUTPUT" + echo "Release plan: event=$EVENT actor=$ACTOR ref=$REF -> publish=$publish stable=$stable" From 1d87c946467bfccf63f92c0e5daa15733c548ab0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 11 Jul 2026 21:44:50 -0700 Subject: [PATCH 2/3] workflows: minimal structured header comments (#277) Reference comment structure for the fleet workflow rework. --- .github/workflows/merge-bot-pull-request.yml | 20 +++++++++----------- .github/workflows/publish-release.yml | 6 +++--- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 5579b082..81655bcf 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,19 +1,17 @@ name: Merge bot pull request action -# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge -# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows -# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs. - -# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and -# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR -# code - each only runs `gh pr merge` against the PR by URL. +# Auto-merges in-repo bot PRs (Dependabot, codegen): enable on opened/reopened, disable on a maintainer push. +# - Merge method by base: develop = squash, main = merge. +# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs. +# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the +# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by URL). on: pull_request_target: types: [opened, reopened, synchronize] -# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR -# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a -# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. +# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would +# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up +# synchronize doesn't cancel an in-flight opened run before it enables auto-merge. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false @@ -146,7 +144,7 @@ jobs: name: Disable auto-merge on maintainer push job runs-on: ubuntu-latest # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the - # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. + # maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent. if: >- github.event.action == 'synchronize' && github.event.pull_request.head.repo.full_name == github.repository && diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 9f8f75d4..7f3688b4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,8 +3,8 @@ name: Publish project release action on: workflow_dispatch: -# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never -# left with a half-created GitHub release. +# A publish is a deliberate dispatch, so runs serialize on one group and queue rather than cancel, so a run is +# never left with a half-created GitHub release. concurrency: group: ${{ github.workflow }} cancel-in-progress: false @@ -46,7 +46,7 @@ jobs: id: nbgv uses: dotnet/nbgv@master - # Skip create on an existing tag (no-op republish); a re-dispatch refreshes it. + # Skip create on an existing tag (no-op republish). A re-dispatch refreshes it. - name: Check for existing release step id: release-exists env: From 1d409b33ccb573e11cd033b8093465929aa58fdc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 11 Jul 2026 21:58:49 -0700 Subject: [PATCH 3/3] docs: reconcile release model to gated single-branch publish (#275) Aligns AGENTS.md + WORKFLOW.md to the gated single-branch release model (delete PUBLISH_ON_MERGE), and corrects the stale build-release-task.yml branch comment to match. --- AGENTS.md | 12 ++++++------ WORKFLOW.md | 14 +++++++------- catalog/snippets/workflows/build-release-task.yml | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d307c802..18ec6e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Treat this file as authoritative for everything else; don't restate its rules el The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. -- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull** - the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change - never mechanical churn. This is the root of the two-phase default - merges do not publish, with `PUBLISH_ON_MERGE` as an explicit opt-in override - together with the no-op republish guarantee and maintainer-gated version bumps: a needless release spends the user's attention and, on a push channel, acts on their machine. +- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull** - the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change - never mechanical churn. This is why a **human merge never auto-publishes** - a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule) - together with the no-op republish guarantee and maintainer-gated version bumps: a needless release spends the user's attention and, on a push channel, acts on their machine. - **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges** - forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only; never back-merge. See "Branching Model". - **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning - the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion - while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line; the toolchain guarantees monotonic builds. See "Release Model". - **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation - two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. @@ -26,7 +26,7 @@ The specific rules in this file implement a few governing principles. Read these ## 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 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`. +- `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 (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. - **Executing a `develop -> main` promotion safely - two traps, both learned the hard way:** @@ -49,11 +49,11 @@ The specific rules in this file implement a few governing principles. Read these The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) 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 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). Workflow YAML is still linted in CI by the lint job's `actionlint` step; also run `actionlint` locally before pushing to catch issues early. -- **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. +- **A human merge never auto-publishes.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher; each run builds the **single trigger branch** (`main` a release, `develop` a prerelease). A first [`plan`](./catalog/snippets/workflows/publish-plan-task.yml) job decides once whether the run publishes and every other job gates on its output. It publishes on a **`workflow_dispatch`** of `main`/`develop` (the human-initiated release), a **code-affecting bot push to `main`** (the codegen App merges every Dependabot/codegen PR, so `github.actor` is the gate - a human merge/promotion to `main` skips), or a **weekly `schedule`** (Docker only, to refresh the base image). The `push` is main-only and paths-filtered, so a develop bot merge and an Actions-only bump publish nothing. A source-only repo publishes on dispatch only. - **Required check.** The `changes` job is in the `Check pull request workflow status job` 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. +- **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`** - each run builds one branch; the top-level publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as `inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. - **Per-target subsetting.** `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 is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./catalog/snippets/workflows/build-release-task.yml), [`get-version-task.yml`](./catalog/snippets/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./catalog/snippets/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. 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 - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. +- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (single-branch publish plan), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./catalog/snippets/workflows/build-release-task.yml), [`get-version-task.yml`](./catalog/snippets/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./catalog/snippets/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. 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 - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. - **What a repo 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. @@ -221,7 +221,7 @@ These conventions describe the target state. New and modified workflows must res - **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')`. - **Artifact retention**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job - for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`) - that also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop - a job that dies before its consumer runs leaves its artifact, reaped within a day - so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. - **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`:buildcache-` for each image, plus the base image's own tag and inline cache); it does not fall back to `type=gha` for the extra images. -- **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). +- **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` (which may differ from the exact commit NBGV versioned) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). ### Running the Linters Locally (Known-Working Invocations) diff --git a/WORKFLOW.md b/WORKFLOW.md index 744a8c6e..9383d2cd 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -102,7 +102,7 @@ flowchart TD ### Release Model -Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **Docker or package** repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a shared paths filter, so a non-substantive change like a GitHub Actions bump publishes nothing), refreshes the released image on a **main-only weekly schedule**, and publishes on manual dispatch. A **source-only** repo publishes on **manual dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. +Each publish builds a **single branch** - the trigger ref (`main` a release, `develop` a prerelease) - so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it; a shared paths filter also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. ```mermaid flowchart TD @@ -152,7 +152,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input ### D4 - Release / Publish -- **D4.1 Symmetric single-branch self-release.** Output: PRs smoke-test and publish nothing. A Docker/package repo self-releases the pushed branch on a release-affecting push to `main` or `develop` (a non-substantive change - e.g. an Actions bump - matches no release path and publishes nothing), refreshes the released image on a main-only weekly schedule, and publishes on dispatch; a source-only repo publishes on dispatch only. Each run builds one branch. +- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing; a **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`; an Actions-only bump matches no release path and publishes nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.* - **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails. - **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* @@ -183,7 +183,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* - **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source; Dependabot targets both branches, security PRs to default. -- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it; the `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish - it ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. ### D9 - Style / Static (See Section 2) @@ -229,13 +229,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the | S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 | | S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 | | S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 | -| S5 | push to `main`/`develop` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 | -| S6 | release-affecting push to `main` or `develop` | that branch self-publishes (`main` a release, `develop` a prerelease) | D3, D4 | -| S7 | publish run (main-only schedule, or a push/dispatch of the branch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it; nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor; `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | | S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | | S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | | S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 | -| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish; it ships via a develop dispatch or promotion) | D8.3, D3.5 | ### 5C. Live Probe (Where Warranted) diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index 9fd33611..89ff883b 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -23,8 +23,8 @@ on: required: false type: string default: '' - # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because the - # publisher builds both `main` and `develop` in one run, so a silent fallback would mislabel the develop leg. + # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each + # publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build. branch: required: true type: string