Skip to content
Merged
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<ver> 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
Expand Down
66 changes: 51 additions & 15 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ name: Docker Multi-Arch Publish
#
# Tags published:
# nself/nself:<version> (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:
Expand All @@ -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<ver>) 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:
Expand All @@ -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
Expand Down Expand Up @@ -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
89 changes: 89 additions & 0 deletions scripts/ci/docker-publish-concurrency_test.sh
Original file line number Diff line number Diff line change
@@ -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<ver> 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 <case>" 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<ver> 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"
33 changes: 33 additions & 0 deletions scripts/ci/docker-publish-promote.sh
Original file line number Diff line number Diff line change
@@ -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<ver> (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}"
58 changes: 58 additions & 0 deletions scripts/ci/docker-publish-promote_test.sh
Original file line number Diff line number Diff line change
@@ -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 <case>" 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"
6 changes: 6 additions & 0 deletions scripts/ci/docker-publish-version_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 - \
Expand Down
Loading