diff --git a/WORKFLOW.md b/WORKFLOW.md index 8b1b5ea9..88cc31e2 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 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 049ff898..4f1f7759 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -142,10 +142,16 @@ jobs: branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} + # 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 }} - needs: [get-version, validate-release] + 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 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"] },