diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 027b73c2d..6eb5b949c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,23 @@ 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 + 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 + + # 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 + - 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..cad9b4a3a 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: @@ -41,8 +42,12 @@ 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 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 + queue: max jobs: docker: @@ -53,7 +58,10 @@ jobs: if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' permissions: contents: read - id-token: write + outputs: + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + latest: ${{ steps.version.outputs.latest }} steps: - uses: actions/checkout@v7.0.1 @@ -114,21 +122,49 @@ 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. 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 + if: needs.docker.outputs.latest == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: docker-publish-latest + cancel-in-progress: false + queue: max + 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 new file mode 100755 index 000000000..7c6c1577c --- /dev/null +++ b/scripts/ci/docker-publish-concurrency_test.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# 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. +# 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; } + +# 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; } + +# 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" + +# 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; } +# 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 + 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" 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 - \