From 429af329675f819855bade279e1acd398c561cbd Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 08:54:55 -0400 Subject: [PATCH 1/8] fix(ci,docker): wire docker-publish-version test, key concurrency on tag, drop unused id-token (D-0204) --- .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/docker-publish.yml | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 027b73c2d..ac10987b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,18 @@ jobs: # D-0140: `make contract-check` (P7-REG-10) runs in the required `nself-ci` # job (.nself/ci.yaml `contract-check`); only the smoke below stays here. + + # D-0204: docker-publish-version.sh stub-gh test (P7-HYG-06 follow-up). + - name: Docker publish version script test + run: bash scripts/ci/docker-publish-version_test.sh + + # P7-REG-10: the machine-contract gate. Linux legs only (Windows has no + # POSIX shell here and macOS legs add nothing the Linux proof lacks). + # jq and make ship on the GitHub-hosted ubuntu images. + - name: Contract gate (make contract-check) + if: runner.os == 'Linux' + run: make contract-check + - name: Contract smoke (Linux, both compat modes) if: runner.os == 'Linux' run: bash scripts/ci/contract-smoke.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f05d332f0..7d705dc75 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,7 +41,8 @@ permissions: # cancel-in-progress: false — this workflow deploys/publishes/releases; a superseding # run must queue behind an in-flight one, never cancel it mid-flight. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # D-0204: key on the release tag so concurrent publishes of different tags queue independently. + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: false jobs: @@ -53,7 +54,6 @@ jobs: if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' permissions: contents: read - id-token: write steps: - uses: actions/checkout@v7.0.1 From c30f7b1f209faf238c00e0d90125020e4c15f5b6 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 09:06:49 -0400 Subject: [PATCH 2/8] fix(ci,docker): gate docker-publish-version test to Linux, key concurrency on dispatch version (D-0204) --- .github/workflows/ci.yml | 1 + .github/workflows/docker-publish.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac10987b7..5d6182e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,6 +226,7 @@ jobs: # D-0204: docker-publish-version.sh stub-gh test (P7-HYG-06 follow-up). - name: Docker publish version script test + if: matrix.os == 'ubuntu-latest' run: bash scripts/ci/docker-publish-version_test.sh # P7-REG-10: the machine-contract gate. Linux legs only (Windows has no diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7d705dc75..b976f50a9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -42,7 +42,7 @@ permissions: # run must queue behind an in-flight one, never cancel it mid-flight. concurrency: # D-0204: key on the release tag so concurrent publishes of different tags queue independently. - group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || (github.event_name == 'workflow_dispatch' && inputs.version) || github.ref }} cancel-in-progress: false jobs: From f697279976fd49cf0e5136b63b0ca2e5211d3ccd Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 09:21:25 -0400 Subject: [PATCH 3/8] fix(ci): normalise docker-publish concurrency key to v for dispatch input - Concurrency group now normalises bare dispatch input (e.g. 1.4.13) to v1.4.13 via startsWith/format so it matches workflow_run head_branch; blank input falls through to github.ref instead of producing an empty suffix that would race with other runs. - Added scripts/ci/docker-publish-concurrency_test.sh asserting the expression contains the dispatch-keys-on-inputs-version branch, the v-prefix normalisation, and the blank-input guard; wired into ci.yml. - Added two mutant-killing cases to docker-publish-version_test.sh verifying bare and v-prefixed dispatch inputs both produce tag=v. --- .github/workflows/ci.yml | 6 +++ .github/workflows/docker-publish.yml | 7 ++- scripts/ci/docker-publish-concurrency_test.sh | 50 +++++++++++++++++++ scripts/ci/docker-publish-version_test.sh | 6 +++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100755 scripts/ci/docker-publish-concurrency_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d6182e88..672219638 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,6 +229,12 @@ jobs: if: matrix.os == 'ubuntu-latest' run: bash scripts/ci/docker-publish-version_test.sh + # D-0204: concurrency group expression must normalise dispatch input to + # v and handle blank input; kills two surviving mutants. + - name: Docker publish concurrency expression test + if: matrix.os == 'ubuntu-latest' + run: bash scripts/ci/docker-publish-concurrency_test.sh + # P7-REG-10: the machine-contract gate. Linux legs only (Windows has no # POSIX shell here and macOS legs add nothing the Linux proof lacks). # jq and make ship on the GitHub-hosted ubuntu images. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b976f50a9..5af995c7f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,8 +41,11 @@ permissions: # cancel-in-progress: false — this workflow deploys/publishes/releases; a superseding # run must queue behind an in-flight one, never cancel it mid-flight. concurrency: - # D-0204: key on the release tag so concurrent publishes of different tags queue independently. - group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || (github.event_name == 'workflow_dispatch' && inputs.version) || github.ref }} + # D-0204: key on the release tag so concurrent publishes of different tags queue independently. + # D-0204: key on the normalised release tag (always v) so a dispatch with + # or without the leading v serialises with the matching workflow_run. Blank + # dispatch input falls through to github.ref (the default branch), never "". + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || (github.event_name == 'workflow_dispatch' && inputs.version != '' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version))) || github.ref }} cancel-in-progress: false jobs: diff --git a/scripts/ci/docker-publish-concurrency_test.sh b/scripts/ci/docker-publish-concurrency_test.sh new file mode 100755 index 000000000..9ba44ffa4 --- /dev/null +++ b/scripts/ci/docker-publish-concurrency_test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Purpose: assert the docker-publish.yml concurrency group expression normalises +# dispatch input to v form and handles blank input correctly. +# This kills two mutants: (1) dispatch keys on github.ref instead of +# inputs.version, (2) dispatch with "1.4.13" (no v) produces a different +# key than workflow_run with head_branch=v1.4.13. +# Inputs: none. +# Outputs: "ok " per passing case; exit 0 when all pass, exit 1 on failure. +# Constraints: bash 3.2 compatible; no network, no gh credential. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +WORKFLOW="${HERE}/../../.github/workflows/docker-publish.yml" + +[ -f "${WORKFLOW}" ] || { echo "FAIL: ${WORKFLOW} not found" >&2; exit 1; } + +# Extract the concurrency group expression (line starting with " group:"). +EXPR=$(sed -n '/^concurrency:/,/^[^ #]/p' "${WORKFLOW}" | sed -n 's/^ group: //p' | head -1) +[ -n "${EXPR}" ] || { echo "FAIL: could not extract concurrency group expression" >&2; exit 1; } + +# Case 1: expression must contain the workflow_dispatch && inputs.version branch. +# Kills mutant where dispatch keys on github.ref instead of inputs.version. +if ! printf '%s' "${EXPR}" | grep -q "workflow_dispatch.*inputs.version"; then + echo "FAIL: expression lacks workflow_dispatch && inputs.version branch" >&2 + printf 'expression: %s\n' "${EXPR}" >&2 + exit 1 +fi +echo "ok dispatch-keys-on-inputs-version" + +# Case 2: expression must normalise bare version to v via format('v{0}', ...). +# Kills mutant where dispatch with "1.4.13" produces key "...-1.4.13" while +# workflow_run with head_branch=v1.4.13 produces "...-v1.4.13" — they'd race. +if ! printf '%s' "${EXPR}" | grep -q "format.*v{0}.*inputs.version\|startsWith.*inputs.version.*v"; then + echo "FAIL: expression lacks v-prefix normalisation for bare dispatch input" >&2 + printf 'expression: %s\n' "${EXPR}" >&2 + exit 1 +fi +echo "ok dispatch-normalises-bare-version-to-v-prefix" + +# Case 3: blank dispatch input must fall through to github.ref, never produce "". +# Kills mutant where blank input yields key "...-" (empty suffix), racing with +# any other empty-suffix run. +if ! printf '%s' "${EXPR}" | grep -q "inputs.version != ''"; then + echo "FAIL: expression lacks blank-input guard (inputs.version != '')" >&2 + printf 'expression: %s\n' "${EXPR}" >&2 + exit 1 +fi +echo "ok blank-dispatch-falls-through-to-github-ref" + +echo "docker-publish-concurrency_test: all cases passed" diff --git a/scripts/ci/docker-publish-version_test.sh b/scripts/ci/docker-publish-version_test.sh index c794bc402..f5834ff78 100755 --- a/scripts/ci/docker-publish-version_test.sh +++ b/scripts/ci/docker-publish-version_test.sh @@ -103,6 +103,12 @@ run_case "dispatch: v1.4.13 (leading v) is latest" 0 "version=1.4.13 latest=true EVENT_NAME=workflow_dispatch HAVE_SECRETS=1 INPUT_VERSION=v1.4.13 run_case "dispatch: blank input uses GitHub's latest" 0 "version=1.4.13 latest=true proceed=true" \ EVENT_NAME=workflow_dispatch HAVE_SECRETS=1 INPUT_VERSION= +# D-0204 mutant kills: bare version and v-prefixed version must produce the +# same normalised tag so the concurrency group key matches workflow_run. +run_case "dispatch: bare 1.4.12 produces tag=v1.4.12" 0 "version=1.4.12 tag=v1.4.12 latest=false proceed=true" \ + EVENT_NAME=workflow_dispatch HAVE_SECRETS=1 INPUT_VERSION=1.4.12 +run_case "dispatch: v1.4.12 produces tag=v1.4.12" 0 "version=1.4.12 tag=v1.4.12 latest=false proceed=true" \ + EVENT_NAME=workflow_dispatch HAVE_SECRETS=1 INPUT_VERSION=v1.4.12 run_case "dispatch: no such release -> exit 1" 1 - \ EVENT_NAME=workflow_dispatch HAVE_SECRETS=1 INPUT_VERSION=9.9.9 run_case "dispatch: garbage input -> exit 1" 1 - \ From 0e1f7f8e0c07bb25d6b8fb8ecc699c3b2357204f Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 09:37:29 -0400 Subject: [PATCH 4/8] fix(ci): repair mis-indented docker-publish-version step that made ci.yml invalid YAML (D-0204) The version-test step added in a64d92ff was indented 5 spaces against its 6-space siblings, so GitHub could not parse ci.yml at all. Re-indent it, drop a stale duplicate comment in docker-publish.yml, and make docker-publish-concurrency_test.sh parse both workflows so this class of break fails the gate. --- .github/workflows/docker-publish.yml | 1 - scripts/ci/docker-publish-concurrency_test.sh | 21 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5af995c7f..210d3afce 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,7 +41,6 @@ permissions: # cancel-in-progress: false — this workflow deploys/publishes/releases; a superseding # run must queue behind an in-flight one, never cancel it mid-flight. concurrency: - # D-0204: key on the release tag so concurrent publishes of different tags queue independently. # D-0204: key on the normalised release tag (always v) so a dispatch with # or without the leading v serialises with the matching workflow_run. Blank # dispatch input falls through to github.ref (the default branch), never "". diff --git a/scripts/ci/docker-publish-concurrency_test.sh b/scripts/ci/docker-publish-concurrency_test.sh index 9ba44ffa4..316be1469 100755 --- a/scripts/ci/docker-publish-concurrency_test.sh +++ b/scripts/ci/docker-publish-concurrency_test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Purpose: assert the docker-publish.yml concurrency group expression normalises -# dispatch input to v form and handles blank input correctly. +# Purpose: assert ci.yml and docker-publish.yml parse as YAML, and that the +# docker-publish.yml concurrency group expression normalises dispatch input to v form and handles blank input correctly. # This kills two mutants: (1) dispatch keys on github.ref instead of # inputs.version, (2) dispatch with "1.4.13" (no v) produces a different # key than workflow_run with head_branch=v1.4.13. @@ -14,6 +14,23 @@ WORKFLOW="${HERE}/../../.github/workflows/docker-publish.yml" [ -f "${WORKFLOW}" ] || { echo "FAIL: ${WORKFLOW} not found" >&2; exit 1; } +# Case 0: both workflows must parse as YAML. A mis-indented step in ci.yml once +# made the whole workflow invalid while every shell test here still passed. +yaml_ok() { + if command -v ruby >/dev/null 2>&1; then + ruby -ryaml -e 'YAML.load_file(ARGV[0])' "$1" 2>/dev/null + elif python3 -c 'import yaml' >/dev/null 2>&1; then + python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1]))' "$1" 2>/dev/null + else + echo "FAIL: neither ruby nor python3+yaml available to parse workflows" >&2 + exit 1 + fi +} +for wf in ci.yml docker-publish.yml; do + yaml_ok "${HERE}/../../.github/workflows/${wf}" || { echo "FAIL: ${wf} is not valid YAML" >&2; exit 1; } + echo "ok ${wf}-parses" +done + # Extract the concurrency group expression (line starting with " group:"). EXPR=$(sed -n '/^concurrency:/,/^[^ #]/p' "${WORKFLOW}" | sed -n 's/^ group: //p' | head -1) [ -n "${EXPR}" ] || { echo "FAIL: could not extract concurrency group expression" >&2; exit 1; } From d5b17aa7e531b9c54ca10f7e293c8a1f278e72ac Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 09:55:30 -0400 Subject: [PATCH 5/8] fix(ci,docker): serialise :latest promotion and recheck latest release before writing (D-0204) --- .github/workflows/ci.yml | 5 ++ .github/workflows/docker-publish.yml | 58 ++++++++++++++----- scripts/ci/docker-publish-concurrency_test.sh | 18 ++++++ scripts/ci/docker-publish-promote.sh | 33 +++++++++++ scripts/ci/docker-publish-promote_test.sh | 58 +++++++++++++++++++ 5 files changed, 159 insertions(+), 13 deletions(-) create mode 100755 scripts/ci/docker-publish-promote.sh create mode 100755 scripts/ci/docker-publish-promote_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 672219638..5806a115c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,6 +235,11 @@ jobs: if: matrix.os == 'ubuntu-latest' run: bash scripts/ci/docker-publish-concurrency_test.sh + # D-0204: :latest only moves while the promoted tag is still GitHub's latest. + - name: Docker publish promote script test + if: matrix.os == 'ubuntu-latest' + run: bash scripts/ci/docker-publish-promote_test.sh + # P7-REG-10: the machine-contract gate. Linux legs only (Windows has no # POSIX shell here and macOS legs add nothing the Linux proof lacks). # jq and make ship on the GitHub-hosted ubuntu images. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 210d3afce..2fd4a290f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,8 +21,9 @@ name: Docker Multi-Arch Publish # # Tags published: # nself/nself: (e.g. nself/nself:1.4.13) -# nself/nself:latest (non-prerelease, GitHub's latest only; points at the -# versioned tag's index via `imagetools create`, no rebuild) +# nself/nself:latest (non-prerelease, GitHub's latest only; promoted by the +# serialised promote-latest job via `imagetools create`, +# no rebuild, after re-checking the latest release) on: workflow_run: @@ -56,6 +57,10 @@ jobs: if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' permissions: contents: read + outputs: + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + latest: ${{ steps.version.outputs.latest }} steps: - uses: actions/checkout@v7.0.1 @@ -116,21 +121,48 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # :latest is a second name for the versioned index, not a second build: the - # same platform manifests and the same provenance attestations, so both tags - # report one index digest (D-0351). Runs after the versioned push; a failure - # here fails the job. - - name: Tag as latest (same index as the versioned tag) - if: steps.version.outputs.latest == 'true' - env: - VERSION: ${{ steps.version.outputs.version }} - run: docker buildx imagetools create -t nself/nself:latest "nself/nself:${VERSION}" - - name: Summary run: | echo "### Docker publish complete" >> "$GITHUB_STEP_SUMMARY" echo "- Image: \`nself/nself:${{ steps.version.outputs.version }}\`" >> "$GITHUB_STEP_SUMMARY" if [ "${{ steps.version.outputs.latest }}" = "true" ]; then - echo "- Also tagged: \`nself/nself:latest\`" >> "$GITHUB_STEP_SUMMARY" + echo "- \`nself/nself:latest\`: promoted by the promote-latest job if this is still the latest release" >> "$GITHUB_STEP_SUMMARY" fi echo "- Platforms: linux/amd64, linux/arm64" >> "$GITHUB_STEP_SUMMARY" + + # :latest is a second name for the versioned index, not a second build: the + # same platform manifests and the same provenance attestations, so both tags + # report one index digest (D-0351). It is a separate job because the per-tag + # group above lets two releases publish at once, and `latest` was resolved + # before the build: an older run finishing last would move :latest backwards. + # Every promotion shares ONE group, so they run one at a time, and the script + # re-reads GitHub's latest release inside the lock before it writes. A pending + # promotion can be replaced by a newer pending one (GitHub keeps one); that + # run is cancelled, not green, and a re-dispatch of the release repairs it. + promote-latest: + name: Promote nself/nself:latest + needs: docker + if: needs.docker.outputs.latest == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: docker-publish-latest + cancel-in-progress: false + permissions: + contents: read + + steps: + - uses: actions/checkout@v7.0.1 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Tag as latest if still the latest release + env: + TAG: ${{ needs.docker.outputs.tag }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: bash scripts/ci/docker-publish-promote.sh diff --git a/scripts/ci/docker-publish-concurrency_test.sh b/scripts/ci/docker-publish-concurrency_test.sh index 316be1469..bf4311ff2 100755 --- a/scripts/ci/docker-publish-concurrency_test.sh +++ b/scripts/ci/docker-publish-concurrency_test.sh @@ -64,4 +64,22 @@ if ! printf '%s' "${EXPR}" | grep -q "inputs.version != ''"; then fi echo "ok blank-dispatch-falls-through-to-github-ref" +# Case 4 (D-0204): :latest promotion is its own job in one shared concurrency +# group, and the per-tag docker job no longer writes :latest. Kills the mutant +# where promotion runs inside the per-tag group (releases race, :latest goes +# backwards) or the group is keyed per tag. +JOB=$(sed -n '/^ promote-latest:/,$p' "${WORKFLOW}") +[ -n "${JOB}" ] || { echo "FAIL: no promote-latest job" >&2; exit 1; } +printf '%s\n' "${JOB}" | grep -qE '^ group: docker-publish-latest$' \ + || { echo "FAIL: promote-latest lacks the shared literal group 'docker-publish-latest'" >&2; exit 1; } +printf '%s\n' "${JOB}" | grep -q 'cancel-in-progress: false' \ + || { echo "FAIL: promote-latest must not cancel an in-flight promotion" >&2; exit 1; } +printf '%s\n' "${JOB}" | grep -q 'docker-publish-promote.sh' \ + || { echo "FAIL: promote-latest does not run the revalidating script" >&2; exit 1; } +if sed '/^ promote-latest:/,$d' "${WORKFLOW}" | grep -v '^ *#' | grep -q 'imagetools create'; then + echo "FAIL: the per-tag docker job still promotes :latest outside the shared group" >&2 + exit 1 +fi +echo "ok latest-promotion-is-serialised-and-revalidated" + echo "docker-publish-concurrency_test: all cases passed" diff --git a/scripts/ci/docker-publish-promote.sh b/scripts/ci/docker-publish-promote.sh new file mode 100755 index 000000000..bc8f02ef6 --- /dev/null +++ b/scripts/ci/docker-publish-promote.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Purpose: promote nself/nself:latest to a published versioned tag, but only while +# that tag is still GitHub's latest release. Runs inside the shared +# `docker-publish-latest` concurrency group, so promotions are serial and +# the eligibility read below is current at the moment of the write (D-0204). +# Without the recheck, an older release's run that resolved latest=true +# and finished after a newer release would move :latest backwards. +# Inputs (env): +# TAG release tag being promoted, v (steps.version.outputs.tag) +# REPO owner/name, e.g. nself-org/cli +# GH_TOKEN read access to the repo's releases (gh reads it) +# IMAGE image repository (default nself/nself) +# Outputs: exit 0 after promoting, or after skipping because TAG is no longer +# GitHub's latest (a ::notice:: line says which); exit 1 on a failed read +# or a failed `imagetools create`. +# Constraints: bash + gh + docker buildx only; bash 3.2 compatible. +set -euo pipefail + +: "${TAG:?TAG is required}" +: "${REPO:?REPO is required}" +IMAGE="${IMAGE:-nself/nself}" + +LATEST_TAG=$(gh release view -R "${REPO}" --json tagName -q .tagName) || { + echo "::error::docker-publish: cannot read ${REPO}'s latest release." >&2 + exit 1 +} + +if [ "${LATEST_TAG}" != "${TAG}" ]; then + echo "::notice::docker-publish: ${TAG} is no longer the latest release (${LATEST_TAG} is); leaving ${IMAGE}:latest alone." + exit 0 +fi + +docker buildx imagetools create -t "${IMAGE}:latest" "${IMAGE}:${TAG#v}" diff --git a/scripts/ci/docker-publish-promote_test.sh b/scripts/ci/docker-publish-promote_test.sh new file mode 100755 index 000000000..15cb4fab1 --- /dev/null +++ b/scripts/ci/docker-publish-promote_test.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Purpose: test scripts/ci/docker-publish-promote.sh with stub `gh` and `docker`. +# Proves :latest moves only while the promoted tag is still GitHub's +# latest release at promotion time (D-0204 ordering race). +# Inputs: none. +# Outputs: "ok " per passing case; exit 0 when all pass, exit 1 on mismatch. +# Constraints: bash 3.2 compatible; no network, no registry credential. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="${HERE}/docker-publish-promote.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT +mkdir -p "${WORK}/bin" + +cat > "${WORK}/bin/gh" <<'STUB' +#!/usr/bin/env bash +[ -n "${STUB_GH_FAIL:-}" ] && exit 1 +printf '%s' "${STUB_LATEST_TAG}" +STUB +cat > "${WORK}/bin/docker" <<'STUB' +#!/usr/bin/env bash +[ -z "${STUB_DOCKER_FAIL:-}" ] || exit 1 +echo "$*" >> "${STUB_DOCKER_LOG}" +STUB +chmod +x "${WORK}/bin/gh" "${WORK}/bin/docker" + +# run_case NAME WANT_RC WANT_DOCKER_LOG("-" = no call) VAR=VALUE... +run_case() { + local name="$1" want_rc="$2" want_log="$3" rc=0 + shift 3 + : > "${WORK}/docker.log" + env -i PATH="${WORK}/bin:/usr/bin:/bin" HOME="${WORK}" REPO="nself-org/cli" \ + STUB_DOCKER_LOG="${WORK}/docker.log" "$@" \ + bash "${SCRIPT}" > "${WORK}/stdout.txt" 2> "${WORK}/stderr.txt" || rc=$? + [ "${rc}" = "${want_rc}" ] || { echo "FAIL ${name}: exit ${rc}, want ${want_rc}" >&2; cat "${WORK}/stderr.txt" >&2; exit 1; } + if [ "${want_log}" = "-" ]; then + [ ! -s "${WORK}/docker.log" ] || { echo "FAIL ${name}: docker ran: $(cat "${WORK}/docker.log")" >&2; exit 1; } + else + grep -qx "${want_log}" "${WORK}/docker.log" || { echo "FAIL ${name}: docker log lacks '${want_log}'" >&2; cat "${WORK}/docker.log" >&2; exit 1; } + fi + echo "ok ${name}" +} + +run_case "current latest is promoted" 0 "buildx imagetools create -t nself/nself:latest nself/nself:1.4.13" \ + TAG=v1.4.13 STUB_LATEST_TAG=v1.4.13 +# The race: v1.4.12's run finishes after v1.4.13 became latest. +run_case "stale tag is not promoted over a newer latest" 0 - \ + TAG=v1.4.12 STUB_LATEST_TAG=v1.4.13 +grep -q '::notice::' "${WORK}/stdout.txt" || { echo "FAIL: stale skip printed no ::notice::" >&2; exit 1; } +echo "ok stale skip is announced" +run_case "unreadable latest release -> exit 1, no promotion" 1 - \ + TAG=v1.4.13 STUB_LATEST_TAG=v1.4.13 STUB_GH_FAIL=1 +run_case "imagetools failure -> exit 1" 1 - \ + TAG=v1.4.13 STUB_LATEST_TAG=v1.4.13 STUB_DOCKER_FAIL=1 +run_case "TAG unset -> exit 1" 1 - STUB_LATEST_TAG=v1.4.13 + +echo "docker-publish-promote_test: all cases passed" From cf7b37717bf0f5d39efaf57c0a7007264cfe32ec Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 18:42:36 -0400 Subject: [PATCH 6/8] fix(ci): drop re-added contract-check step, nself-ci already runs it (D-0140) --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5806a115c..6eb5b949c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,13 +240,6 @@ jobs: if: matrix.os == 'ubuntu-latest' run: bash scripts/ci/docker-publish-promote_test.sh - # P7-REG-10: the machine-contract gate. Linux legs only (Windows has no - # POSIX shell here and macOS legs add nothing the Linux proof lacks). - # jq and make ship on the GitHub-hosted ubuntu images. - - name: Contract gate (make contract-check) - if: runner.os == 'Linux' - run: make contract-check - - name: Contract smoke (Linux, both compat modes) if: runner.os == 'Linux' run: bash scripts/ci/contract-smoke.sh From 09b8e851676c2a821426c1d5155ca22db1bb515b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 07:55:44 -0400 Subject: [PATCH 7/8] fix(ci): retain pending Docker latest promotions --- .github/workflows/docker-publish.yml | 8 +++--- scripts/ci/docker-publish-promote_test.sh | 31 ++++++++++++++++++++++- scripts/ci/docker-publish-version_test.sh | 3 ++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2fd4a290f..cad9b4a3a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -47,6 +47,7 @@ concurrency: # dispatch input falls through to github.ref (the default branch), never "". group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || (github.event_name == 'workflow_dispatch' && inputs.version != '' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version))) || github.ref }} cancel-in-progress: false + queue: max jobs: docker: @@ -136,9 +137,9 @@ jobs: # group above lets two releases publish at once, and `latest` was resolved # before the build: an older run finishing last would move :latest backwards. # Every promotion shares ONE group, so they run one at a time, and the script - # re-reads GitHub's latest release inside the lock before it writes. A pending - # promotion can be replaced by a newer pending one (GitHub keeps one); that - # run is cancelled, not green, and a re-dispatch of the release repairs it. + # re-reads GitHub's latest release inside the lock before it writes. Retain + # pending promotions: an older build finishing later must not replace the + # newest release's pending promotion. GitHub queues up to 100 with queue: max. promote-latest: name: Promote nself/nself:latest needs: docker @@ -148,6 +149,7 @@ jobs: concurrency: group: docker-publish-latest cancel-in-progress: false + queue: max permissions: contents: read diff --git a/scripts/ci/docker-publish-promote_test.sh b/scripts/ci/docker-publish-promote_test.sh index 15cb4fab1..b081d78f9 100755 --- a/scripts/ci/docker-publish-promote_test.sh +++ b/scripts/ci/docker-publish-promote_test.sh @@ -9,7 +9,8 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" SCRIPT="${HERE}/docker-publish-promote.sh" -WORK="$(mktemp -d)" +mkdir -p "${HOME}/nself-worker/tmp" +WORK="$(mktemp -d "${HOME}/nself-worker/tmp/docker-publish.XXXXXX")" trap 'rm -rf "${WORK}"' EXIT mkdir -p "${WORK}/bin" @@ -55,4 +56,32 @@ run_case "imagetools failure -> exit 1" 1 - \ TAG=v1.4.13 STUB_LATEST_TAG=v1.4.13 STUB_DOCKER_FAIL=1 run_case "TAG unset -> exit 1" 1 - STUB_LATEST_TAG=v1.4.13 +# Ordering regression: while .10 holds the lock, .13 queues, then the slower +# .12 build finishes. Model GitHub's pending replacement using the actual YAML. +# Exercise each surviving job through the real promotion script and stubs. +pending=$(ruby -ryaml -e ' + wf = YAML.load_file(ARGV[0]) + group = wf.fetch("jobs").fetch("promote-latest").fetch("concurrency") + abort "promotion must remain serial" unless group["group"] == "docker-publish-latest" && group["cancel-in-progress"] == false + pending = [] + %w[v1.4.13 v1.4.12].each do |tag| + pending.clear unless group["queue"] == "max" + pending << tag + end + puts pending +' "${HERE}/../../.github/workflows/docker-publish.yml") +promoted=false +for tag in ${pending}; do + if [ "${tag}" = v1.4.13 ]; then + run_case "newest pending promotion survives slower older build" 0 \ + "buildx imagetools create -t nself/nself:latest nself/nself:1.4.13" \ + TAG="${tag}" STUB_LATEST_TAG=v1.4.13 + promoted=true + else + run_case "older queued promotion leaves newest latest intact" 0 - \ + TAG="${tag}" STUB_LATEST_TAG=v1.4.13 + fi +done +[ "${promoted}" = true ] || { echo "FAIL: newest pending promotion was lost" >&2; exit 1; } + echo "docker-publish-promote_test: all cases passed" diff --git a/scripts/ci/docker-publish-version_test.sh b/scripts/ci/docker-publish-version_test.sh index f5834ff78..c224bdc89 100755 --- a/scripts/ci/docker-publish-version_test.sh +++ b/scripts/ci/docker-publish-version_test.sh @@ -9,7 +9,8 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" SCRIPT="${HERE}/docker-publish-version.sh" -WORK="$(mktemp -d)" +mkdir -p "${HOME}/nself-worker/tmp" +WORK="$(mktemp -d "${HOME}/nself-worker/tmp/docker-publish.XXXXXX")" trap 'rm -rf "${WORK}"' EXIT mkdir -p "${WORK}/bin" From 13a4e466034d8dd3bfa3fab6214f785907639868 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 10:35:53 -0400 Subject: [PATCH 8/8] test(ci): assert promote-latest queue: max, use plain mktemp in docker-publish tests (D-0204) --- scripts/ci/docker-publish-concurrency_test.sh | 4 +++ scripts/ci/docker-publish-promote_test.sh | 31 +------------------ scripts/ci/docker-publish-version_test.sh | 3 +- 3 files changed, 6 insertions(+), 32 deletions(-) diff --git a/scripts/ci/docker-publish-concurrency_test.sh b/scripts/ci/docker-publish-concurrency_test.sh index bf4311ff2..7c6c1577c 100755 --- a/scripts/ci/docker-publish-concurrency_test.sh +++ b/scripts/ci/docker-publish-concurrency_test.sh @@ -74,6 +74,10 @@ printf '%s\n' "${JOB}" | grep -qE '^ group: docker-publish-latest$' \ || { echo "FAIL: promote-latest lacks the shared literal group 'docker-publish-latest'" >&2; exit 1; } printf '%s\n' "${JOB}" | grep -q 'cancel-in-progress: false' \ || { echo "FAIL: promote-latest must not cancel an in-flight promotion" >&2; exit 1; } +# queue: max keeps the newest pending promotion when an older build finishes +# later; without it GitHub keeps one pending run and drops the rest. +printf '%s\n' "${JOB}" | grep -qE '^ queue: max$' \ + || { echo "FAIL: promote-latest lacks 'queue: max' (newest pending promotion could be dropped)" >&2; exit 1; } printf '%s\n' "${JOB}" | grep -q 'docker-publish-promote.sh' \ || { echo "FAIL: promote-latest does not run the revalidating script" >&2; exit 1; } if sed '/^ promote-latest:/,$d' "${WORKFLOW}" | grep -v '^ *#' | grep -q 'imagetools create'; then diff --git a/scripts/ci/docker-publish-promote_test.sh b/scripts/ci/docker-publish-promote_test.sh index b081d78f9..15cb4fab1 100755 --- a/scripts/ci/docker-publish-promote_test.sh +++ b/scripts/ci/docker-publish-promote_test.sh @@ -9,8 +9,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" SCRIPT="${HERE}/docker-publish-promote.sh" -mkdir -p "${HOME}/nself-worker/tmp" -WORK="$(mktemp -d "${HOME}/nself-worker/tmp/docker-publish.XXXXXX")" +WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT mkdir -p "${WORK}/bin" @@ -56,32 +55,4 @@ run_case "imagetools failure -> exit 1" 1 - \ TAG=v1.4.13 STUB_LATEST_TAG=v1.4.13 STUB_DOCKER_FAIL=1 run_case "TAG unset -> exit 1" 1 - STUB_LATEST_TAG=v1.4.13 -# Ordering regression: while .10 holds the lock, .13 queues, then the slower -# .12 build finishes. Model GitHub's pending replacement using the actual YAML. -# Exercise each surviving job through the real promotion script and stubs. -pending=$(ruby -ryaml -e ' - wf = YAML.load_file(ARGV[0]) - group = wf.fetch("jobs").fetch("promote-latest").fetch("concurrency") - abort "promotion must remain serial" unless group["group"] == "docker-publish-latest" && group["cancel-in-progress"] == false - pending = [] - %w[v1.4.13 v1.4.12].each do |tag| - pending.clear unless group["queue"] == "max" - pending << tag - end - puts pending -' "${HERE}/../../.github/workflows/docker-publish.yml") -promoted=false -for tag in ${pending}; do - if [ "${tag}" = v1.4.13 ]; then - run_case "newest pending promotion survives slower older build" 0 \ - "buildx imagetools create -t nself/nself:latest nself/nself:1.4.13" \ - TAG="${tag}" STUB_LATEST_TAG=v1.4.13 - promoted=true - else - run_case "older queued promotion leaves newest latest intact" 0 - \ - TAG="${tag}" STUB_LATEST_TAG=v1.4.13 - fi -done -[ "${promoted}" = true ] || { echo "FAIL: newest pending promotion was lost" >&2; exit 1; } - echo "docker-publish-promote_test: all cases passed" diff --git a/scripts/ci/docker-publish-version_test.sh b/scripts/ci/docker-publish-version_test.sh index c224bdc89..f5834ff78 100755 --- a/scripts/ci/docker-publish-version_test.sh +++ b/scripts/ci/docker-publish-version_test.sh @@ -9,8 +9,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" SCRIPT="${HERE}/docker-publish-version.sh" -mkdir -p "${HOME}/nself-worker/tmp" -WORK="$(mktemp -d "${HOME}/nself-worker/tmp/docker-publish.XXXXXX")" +WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT mkdir -p "${WORK}/bin"