From 3d4b88234a03142379e8cf08ae0c52f453a6ae50 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 10:30:44 -0700 Subject: [PATCH] Block a partial publish when a build fails WORKFLOW.md D4.5 requires that a build failure block every publish target. This repo's WORKFLOW.md was missing the guarantee entirely, and the pipeline did not satisfy it. build-docker needed only [get-version, validate, validate-release] and guarded solely on cancellation. The image builds from source and consumes no executable artifact, so the two builds were independent and nothing forced an ordering. On a real publish where build-executable failed, github-release skipped, so no tag and no release were cut, while build-docker was untouched and pushed the multi-arch image anyway, moving `latest`. An image shipped with no release behind it. build-docker becomes the terminal publish target: it needs build-executable, and its `if` gains `!failure()`. The distinction that matters is failed versus skipped. A failed upstream build must stop the push, and a skipped one must not, because `validate` is skipped on every smoke run and that run still has to build the image. The explicit get-version and validate-release result checks stay, so a job that somehow did not run cannot feed empty version inputs into a build. The cost is on smoke, where build-docker now waits for build-executable instead of running beside it, adding roughly the executable build's duration to PR feedback. Correctness over a subset of the wall clock. WORKFLOW.md gains D4.5 in the same commit rather than in the doc-refresh PR ahead of it, so the contract and the code that satisfies it land together instead of the file claiming a guarantee the pipeline breaks. The failure path is not reachable from a pull request, since smoke never publishes, so it is established from the needs graph rather than by observation. actionlint passes. Audit run 2026-08-03T16:52:36Z, hub 1ed0cc8, against develop@39c896b. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-release-task.yml | 10 ++++++++-- WORKFLOW.md | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 6866be2..ef8404a 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -103,10 +103,16 @@ jobs: assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} + # The terminal publish target, so it needs every other build (D4.5). + # The image builds from source and consumes no executable artifact, so nothing else forces the ordering. + # Without it a failed build-executable skips github-release while the image still pushes and `latest` moves. + # That is a partial publish: an image with no release. + # `!failure()` is what blocks it, since a failed upstream build must stop the push where a skipped one must not. + # `validate` is skipped on smoke, which is the skip that must still build. build-docker: name: Build Docker job - needs: [get-version, validate, validate-release] - if: ${{ !cancelled() && needs.get-version.result == 'success' && needs.validate-release.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} + needs: [get-version, validate, validate-release, build-executable] + if: ${{ !failure() && !cancelled() && needs.get-version.result == 'success' && needs.validate-release.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: diff --git a/WORKFLOW.md b/WORKFLOW.md index aeeeeb0..e87d452 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 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 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, because 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 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