diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 81655bcf..596e436c 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -42,7 +42,7 @@ jobs: # Auto-merge every tier, semver-major included: the required checks are the gate, not the bump magnitude. - name: Merge pull request step run: | - set -euo pipefail + set -Eeuo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; @@ -84,7 +84,7 @@ jobs: - name: Merge pull request step run: | - set -euo pipefail + set -Eeuo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; @@ -126,7 +126,7 @@ jobs: - name: Merge pull request step run: | - set -euo pipefail + set -Eeuo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 951c7ec1..264960a4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -31,7 +31,7 @@ jobs: - name: Assert dispatch ref step run: | - set -euo pipefail + set -Eeuo pipefail if [ "${{ github.ref_name }}" != "main" ] && [ "${{ github.ref_name }}" != "develop" ]; then echo "::error::Dispatch publish-release from main (release) or develop (prerelease); got ${{ github.ref_name }}." exit 1 diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index dc4c1c20..115f52c1 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Check workflow results step run: | - set -euo pipefail + set -Eeuo pipefail if [[ "${{ needs.validate.result }}" != "success" ]]; then echo "Job 'validate' did not succeed (${{ needs.validate.result }}); refusing to pass." exit 1 diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index e66644d3..1125899f 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -43,7 +43,7 @@ jobs: - name: Validate registry and spec step run: | - set -euo pipefail + set -Eeuo pipefail for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f" done diff --git a/AGENTS.md b/AGENTS.md index f20ab9c2..e6b0f7a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,7 +227,7 @@ These conventions describe the target state. New and modified workflows must res - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"** - including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together** - update the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. - **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order - cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. -- **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. +- **Shells**: every bash surface - a multi-line `run:` block and every committed `.sh` script alike - starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. - **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`](./catalog/snippets/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". diff --git a/WORKFLOW.md b/WORKFLOW.md index e3f2f43e..2431bb3b 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -32,7 +32,7 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien - **Workflow `name:`.** Reusable names end in **"task"**; entry-point names end in **"action"**. - **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"** - including a ruleset-bound required-check job, whose `name:` and the ruleset `context:` are one string renamed together (never independently). - **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). -- **Shells.** Every multi-line bash `run:` starts `set -euo pipefail`. +- **Shells.** Every multi-line bash `run:` - and every committed `.sh` script - starts `set -Eeuo pipefail`. - **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. - **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks; `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege; a reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. @@ -201,7 +201,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules; a ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). -- **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. +- **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`; multi-line `if:` uses `>-`. - **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. - **D9.5** Line endings follow `.editorconfig`. diff --git a/catalog/snippets/devcontainer/dotnet/post-create.sh b/catalog/snippets/devcontainer/dotnet/post-create.sh index fcb6609a..91c058fa 100755 --- a/catalog/snippets/devcontainer/dotnet/post-create.sh +++ b/catalog/snippets/devcontainer/dotnet/post-create.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail # Restore the .NET local-tool manifest (csharpier, dotnet-outdated). dotnet tool restore diff --git a/catalog/snippets/devcontainer/python/post-create.sh b/catalog/snippets/devcontainer/python/post-create.sh index b18a729a..bfd59a8a 100755 --- a/catalog/snippets/devcontainer/python/post-create.sh +++ b/catalog/snippets/devcontainer/python/post-create.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail # Install uv (Astral) for the Python project. Idempotent - re-running # overwrites in place. The installer drops the binary in $HOME/.local/bin and diff --git a/catalog/snippets/workflows/build-executable-task.yml b/catalog/snippets/workflows/build-executable-task.yml index b18a3d6f..20582689 100644 --- a/catalog/snippets/workflows/build-executable-task.yml +++ b/catalog/snippets/workflows/build-executable-task.yml @@ -53,6 +53,7 @@ jobs: - name: Build executable project step run: | + set -Eeuo pipefail dotnet publish ./Console/Console.csproj \ --runtime ${{ matrix.runtime }} \ -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \ diff --git a/catalog/snippets/workflows/build-nugetlibrary-task.yml b/catalog/snippets/workflows/build-nugetlibrary-task.yml index fc21ba61..8bcfd0d7 100644 --- a/catalog/snippets/workflows/build-nugetlibrary-task.yml +++ b/catalog/snippets/workflows/build-nugetlibrary-task.yml @@ -52,7 +52,7 @@ jobs: - name: Build NuGet library project step run: | - set -euo pipefail + set -Eeuo pipefail dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ @@ -66,7 +66,7 @@ jobs: - name: Publish to NuGet.org step if: ${{ inputs.push }} run: | - set -euo pipefail + set -Eeuo pipefail dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \ --source https://api.nuget.org/v3/index.json \ --api-key ${{ secrets.NUGET_API_KEY }} \ diff --git a/catalog/snippets/workflows/build-pypilibrary-task.yml b/catalog/snippets/workflows/build-pypilibrary-task.yml index 5cc0b71e..8efb9640 100644 --- a/catalog/snippets/workflows/build-pypilibrary-task.yml +++ b/catalog/snippets/workflows/build-pypilibrary-task.yml @@ -84,7 +84,7 @@ jobs: - name: Compute PyPI version step id: pypiver run: | - set -euo pipefail + set -Eeuo pipefail if [[ "$BRANCH" == "develop" ]]; then version="${AFV}.dev0" else @@ -100,7 +100,7 @@ jobs: # Done after tests so the test asserting __version__ is non-empty isn't affected. - name: Write version into _version.py step run: | - set -euo pipefail + set -Eeuo pipefail sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py env: VERSION: ${{ steps.pypiver.outputs.version }} diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index 89ff883b..049ff898 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -83,7 +83,7 @@ jobs: BRANCH: ${{ inputs.branch }} SMOKE: ${{ inputs.smoke }} run: | - set -euo pipefail + set -Eeuo pipefail # Smoke builds never publish and always version as prerelease (detached PR HEAD), which would trip the main arm. if [[ "$SMOKE" == "true" ]]; then echo "Smoke build; skipping release version validation." @@ -193,7 +193,7 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.get-version.outputs.SemVer2 }} run: | - set -euo pipefail + set -Eeuo pipefail if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then @@ -240,7 +240,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail + set -Eeuo pipefail if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then echo "::warning::Could not list run artifacts; retention-days backstop will reap them." diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml index c4962d58..e5a14845 100644 --- a/catalog/snippets/workflows/check-upstream-version-task.yml +++ b/catalog/snippets/workflows/check-upstream-version-task.yml @@ -69,7 +69,7 @@ jobs: RESOLVER_COMMAND: ${{ inputs.resolver-command }} STATE_FILE: ${{ inputs.state-file }} run: | - set -euo pipefail + set -Eeuo pipefail # Require a non-empty JSON object of single-line name -> version strings; a CR/LF would corrupt the # single-line GITHUB_OUTPUT, so reject it here instead of committing unconsumable state. diff --git a/catalog/snippets/workflows/publish-docker-readme-task.yml b/catalog/snippets/workflows/publish-docker-readme-task.yml index a4cb3f20..1c80cec5 100644 --- a/catalog/snippets/workflows/publish-docker-readme-task.yml +++ b/catalog/snippets/workflows/publish-docker-readme-task.yml @@ -70,7 +70,7 @@ jobs: MANIFEST: ${{ inputs.manifest }} MANIFEST_JQ: ${{ inputs.manifest-jq }} run: | - set -euo pipefail + set -Eeuo pipefail if [ -n "$REPOSITORIES" ] && [ -n "$MANIFEST" ]; then echo "::error::Pass either 'repositories' or 'manifest', not both." >&2 exit 1 @@ -94,7 +94,7 @@ jobs: MANIFEST: ${{ inputs.manifest }} MANIFEST_JQ: ${{ inputs.manifest-jq }} run: | - set -euo pipefail + set -Eeuo pipefail # Inputs validated above: at most one of repositories / manifest is set, and manifest implies manifest-jq. if [ -n "$REPOSITORIES" ]; then echo "repositories=$REPOSITORIES" >> "$GITHUB_OUTPUT" @@ -133,7 +133,7 @@ jobs: - name: Generate readme step if: ${{ inputs.transform-run != '' }} run: | - set -euo pipefail + set -Eeuo pipefail ${{ inputs.transform-run }} - name: Publish Docker Hub readme step diff --git a/catalog/snippets/workflows/publish-plan-task.yml b/catalog/snippets/workflows/publish-plan-task.yml index ab7dbbb9..8eaef7f5 100644 --- a/catalog/snippets/workflows/publish-plan-task.yml +++ b/catalog/snippets/workflows/publish-plan-task.yml @@ -57,7 +57,7 @@ jobs: ACTOR: ${{ inputs.actor }} REF: ${{ inputs.ref_name }} run: | - set -euo pipefail + set -Eeuo pipefail publish=false case "$EVENT" in workflow_dispatch) diff --git a/catalog/snippets/workflows/run-codegen-pull-request-task.yml b/catalog/snippets/workflows/run-codegen-pull-request-task.yml index beab7e94..9b71ab00 100644 --- a/catalog/snippets/workflows/run-codegen-pull-request-task.yml +++ b/catalog/snippets/workflows/run-codegen-pull-request-task.yml @@ -54,14 +54,14 @@ jobs: - name: Run codegen step run: | - set -euo pipefail + set -Eeuo pipefail dotnet run --project ./CodeGen/CodeGen.csproj -- \ --codepath ./CodeGen \ --apikey "${{ secrets.NINJA_API_KEY }}" - name: Format code step run: | - set -euo pipefail + set -Eeuo pipefail dotnet tool restore dotnet csharpier format --log-level=debug . git status diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 5c4c653c..5082ea0f 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -15,7 +15,7 @@ # Usage: repo-config/configure.sh [owner/repo] [release|operational] (repo defaults to the current repo via gh; # model defaults to the registry lookup, else payload inference). The model may also be passed as the sole # argument: repo-config/configure.sh operational -set -euo pipefail +set -Eeuo pipefail repo_arg="${1:-}" model="${2:-}"