diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index d2e22676..1a8f35eb 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -33,6 +33,7 @@ jobs: secrets: inherit with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-docker: name: Build Docker image job diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index ab5add7e..d5e43d7a 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -28,6 +28,7 @@ jobs: secrets: inherit with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-executable-matrix: name: Build executable project matrix job diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index a0311beb..07cd46a2 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -32,6 +32,7 @@ jobs: uses: ./.github/workflows/get-version-task.yml with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-nugetlibrary: name: Build NuGet library project job diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 61885a54..c79a67ca 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -36,6 +36,7 @@ jobs: uses: ./.github/workflows/get-version-task.yml with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-pypilibrary: name: Build PyPI library project job diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 46935f9f..a23bbbef 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -67,11 +67,38 @@ jobs: secrets: inherit with: ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + + # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails + # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch + # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip + # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. + validate-release: + name: Validate release version job + needs: [get-version] + runs-on: ubuntu-latest + steps: + - name: Validate branch and version consistency step + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + CORE_AND_PRE="${SEMVER2%%+*}" + if [[ "$BRANCH" == "main" ]]; then + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + elif [[ "$CORE_AND_PRE" != *-* ]]; then + echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish." + exit 1 + fi build-nugetlibrary: name: Build NuGet library job if: ${{ inputs.enable_nuget }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: @@ -88,7 +115,7 @@ jobs: build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit with: @@ -101,7 +128,7 @@ jobs: build-executable: name: Build executable job if: ${{ inputs.enable_executable }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-executable-task.yml secrets: inherit with: @@ -113,7 +140,7 @@ jobs: build-docker: name: Build Docker job if: ${{ inputs.enable_docker }} - needs: [get-version] + needs: [get-version, validate-release] uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: @@ -130,7 +157,7 @@ jobs: # `github: true` still can't create a release. if: ${{ inputs.github && !inputs.smoke }} runs-on: ubuntu-latest - needs: [get-version, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] + needs: [get-version, validate-release, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] steps: @@ -140,21 +167,6 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Backstop (main only): a public release must not carry a prerelease '-', guarding against NBGV mis-versioning the - # public ref (e.g. a dispatch on a non-default ref) into a malformed "Latest" release. Strip '+buildmetadata' - # first - a '-' there is legitimate; only a '-' in the core/prerelease segment marks a prerelease. - - name: Verify public release version step - if: ${{ inputs.branch == 'main' }} - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 900e0630..36fc28b9 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -9,6 +9,12 @@ on: required: false type: string default: '' + # Logical branch NBGV classifies against. Pins PublicRelease to this branch instead of the runner's GITHUB_REF, + # which on a publish dispatched from the default branch is that branch for every matrix leg. Empty keeps GITHUB_REF. + branch: + required: false + type: string + default: '' outputs: SemVer2: value: ${{ jobs.get-version.outputs.SemVer2 }} @@ -52,3 +58,8 @@ jobs: - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master + env: + # NBGV reads the branch from GITHUB_REF; pin it to the leg being versioned so a publish dispatched from the + # default branch doesn't classify every leg as the public ref and strip its prerelease suffix. + GITHUB_REF: ${{ inputs.branch != '' && format('refs/heads/{0}', inputs.branch) || github.ref }} + GITHUB_REF_NAME: ${{ inputs.branch != '' && inputs.branch || github.ref_name }} diff --git a/AGENTS.md b/AGENTS.md index bdfb73d6..90be545e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,7 @@ These conventions describe the target state. New and modified workflows must res - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./.github/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./.github/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **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. **Every workflow that can produce artifacts ends with a terminal `cleanup-artifacts` job** that deletes the run's artifacts via the REST API: `permissions: actions: write`, `needs` the artifact producers, an `if:` that **includes** `always()` (so a failed run still cleans up) plus any workflow-specific gate (e.g. `publish-release.yml` adds `&& needs.setup.outputs.publish == 'true'` to run only on real publishes), independent of any required status check so housekeeping never gates a merge, `continue-on-error: true` on the delete step so even an unexpected failure never reds the run, and tolerant of individual list/delete failures (warn and continue). This covers not just `actions/upload-artifact` but build-records that actions emit automatically (e.g. `docker/build-push-action`'s `.dockerbuild`). Both `publish-release.yml` and `test-pull-request.yml` carry one; add one to any new artifact-producing entry workflow. Set `retention-days: 1` on explicit uploads as a backstop.