From e5a6f3033b2ffad1de2098bd83e17b55da46bb85 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 23 Jul 2026 16:17:46 -0700 Subject: [PATCH 1/2] Gate the Docker push on all builds; require validate-release (#414 Q4) Full publish atomicity (#414 Q4): github-release already needs every build, but build-docker built and pushed the image gated only on validate-release, so a failure in a different build (e.g. the executable) still let the image push - a partial publish. Docker is the terminal registry push in the fleet (no repo pushes two registries at once), so make build-docker need every other build: the image publishes only when all enabled builds passed. A skipped (disabled) build does not block, a failed one does. No needs cycle (nothing needs docker back). Documented as WORKFLOW.md D4.5. Also require validate-release in the release-task interface contract. It is the symmetric branch<->version entry gate (D2.2); the older repos carry that check as a step inside github-release instead, so the contract flags them to adopt the entry-gate job on their next re-vendor. #414 Q1 (symmetric backstop + main/develop whitelist) and Q2 (expect_release_ assets) were already implemented (validate-release / D2.2, publish-plan / D2.3, and the expect_release_assets input); Q3 landed in #415. Q4 is the remainder. Co-Authored-By: Claude Opus 4.8 --- WORKFLOW.md | 1 + catalog/snippets/workflows/build-release-task.yml | 6 +++++- spec/files.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 8b1b5ea9..ed2559ed 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -168,6 +168,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved 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 that uses the release task (Docker-only, PyPI-only) 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. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. - **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.* +- **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes - `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build too, so a partial run never pushes the image. A skipped (disabled) build does not block, a failed one does. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. ### D5 - Resource Cleanup diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index 049ff898..ba455e91 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -142,10 +142,14 @@ jobs: branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} + # Docker is the terminal registry push, so it needs every other build: the image is pushed only when all + # enabled builds passed, never on a partial run where another target failed. A skipped (disabled) build does + # not block, a failed one does - github-release gates the same way, so a build failure blocks every publish + # target. (A repo that also pushes a package alongside docker would need a build/publish split, not this gate.) build-docker: name: Build Docker job if: ${{ inputs.enable_docker }} - needs: [get-version, validate-release] + needs: [get-version, validate-release, build-nugetlibrary, build-pypilibrary, build-executable] uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: diff --git a/spec/files.json b/spec/files.json index 8b4a9dad..c4678bbb 100644 --- a/spec/files.json +++ b/spec/files.json @@ -25,7 +25,7 @@ { "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": ".github/dependabot.yml", "appliesTo": "*" }, { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "AGENTS.md#workflow-yaml-conventions", "appliesTo": "*" }, - { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "AGENTS.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, + { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "AGENTS.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] }, { "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, From 9f75fd0eaf22753cef3935f216af7c21852453d3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 23 Jul 2026 16:25:29 -0700 Subject: [PATCH 2/2] Address Copilot: guard build-docker so a skipped need does not skip it A skipped GitHub Actions dependency skips the dependent, so build-docker needing every build would be skipped whenever a target is disabled - breaking smoke PRs that build only the changed target. Guard build-docker's if with !failure() && !cancelled(): a failed build still skips docker (atomicity holds), but a skipped (disabled or unvendored) build is tolerated so docker still builds on smoke. Corrected the comment and D4.5 to describe this, not plain-needs semantics. Co-Authored-By: Claude Opus 4.8 --- WORKFLOW.md | 2 +- catalog/snippets/workflows/build-release-task.yml | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index ed2559ed..88cc31e2 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -168,7 +168,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved 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 that uses the release task (Docker-only, PyPI-only) 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. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. - **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.* -- **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes - `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build too, so a partial run never pushes the image. A skipped (disabled) build does not block, a failed one does. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. +- **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes - `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target - skipped, not failed - still lets docker build on smoke. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. ### D5 - Resource Cleanup diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index ba455e91..4f1f7759 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -142,13 +142,15 @@ jobs: branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} - # Docker is the terminal registry push, so it needs every other build: the image is pushed only when all - # enabled builds passed, never on a partial run where another target failed. A skipped (disabled) build does - # not block, a failed one does - github-release gates the same way, so a build failure blocks every publish - # target. (A repo that also pushes a package alongside docker would need a build/publish split, not this gate.) + # Docker is the terminal registry push, so it must never push on a partial run. It needs every other build and + # guards with `!failure() && !cancelled()`: a *failed* build skips docker (no build, no push), while a *skipped* + # build - a target disabled on a smoke PR, or one this repo does not vendor - does not, so docker still builds + # the changed target on smoke. Plain `needs` cannot express this (a skipped need skips the dependent). The + # github-release job reaches the same intent more simply because it only runs on a publish (`!inputs.smoke`), + # where nothing is disabled. (A repo pushing a package alongside docker would need a build/publish split.) build-docker: name: Build Docker job - if: ${{ inputs.enable_docker }} + if: ${{ inputs.enable_docker && !failure() && !cancelled() }} needs: [get-version, validate-release, build-nugetlibrary, build-pypilibrary, build-executable] uses: ./.github/workflows/build-docker-task.yml secrets: inherit