From 77279e16ab09738c47b0fef75d5020ad38b8cb1c Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 21:15:13 -0400 Subject: [PATCH 1/4] ci(release): mint v*-rigel.N tags from rigel-release (RIG-5346) Add .github/rigel-version (1.1.0-rigel.1), the release-tag workflow and its script, and run the script test under the required CI check. Co-authored-by: Matt Wilkinson --- .github/rigel-version | 1 + .github/scripts/release-tag.sh | 142 ++++++++++++++++++++++++++++ .github/scripts/release-tag.test.sh | 116 +++++++++++++++++++++++ .github/workflows/golang.yml | 11 ++- .github/workflows/release-tag.yaml | 63 ++++++++++++ 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 .github/rigel-version create mode 100755 .github/scripts/release-tag.sh create mode 100755 .github/scripts/release-tag.test.sh create mode 100644 .github/workflows/release-tag.yaml diff --git a/.github/rigel-version b/.github/rigel-version new file mode 100644 index 0000000..a130a40 --- /dev/null +++ b/.github/rigel-version @@ -0,0 +1 @@ +1.1.0-rigel.1 diff --git a/.github/scripts/release-tag.sh b/.github/scripts/release-tag.sh new file mode 100755 index 0000000..318a5b5 --- /dev/null +++ b/.github/scripts/release-tag.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Validate nix2container version changes or mint their deterministic release tag. +set -euo pipefail + +readonly VERSION_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rigel\.([1-9][0-9]*)$' +export LC_ALL=C + +usage() { + echo 'Usage: release-tag.sh check-pr | mint ' >&2 + exit 2 +} + +version_at() { + local commit="$1" required="${2:-yes}" versions=() + mapfile -t versions < <(git show "${commit}:.github/rigel-version" 2>/dev/null | sed -n 'p') + if [ "${#versions[@]}" -ne 1 ]; then + if [ "$required" = yes ]; then + echo "::error::expected exactly one .github/rigel-version line at ${commit}, found ${#versions[@]}" >&2 + return 1 + fi + return 0 + fi + printf '%s\n' "${versions[0]}" +} + +compare_decimal() { + local left="$1" right="$2" + [ "${#left}" -gt "${#right}" ] || { [ "${#left}" -eq "${#right}" ] && [[ "$left" > "$right" ]]; } +} + +increment_decimal() { + local number="$1" i digit carry=1 result= + for ((i = ${#number} - 1; i >= 0; i--)); do + digit="${number:i:1}" + if [ "$carry" -eq 1 ]; then + if [ "$digit" = 9 ]; then + result="0${result}" + else + result="$((digit + 1))${result}" + carry=0 + fi + else + result="${digit}${result}" + fi + done + if [ "$carry" -eq 1 ]; then result="1${result}"; fi + printf '%s\n' "$result" +} + +validate_next_version() { + local version="$1" base tag max='' expected + if [[ ! "$version" =~ $VERSION_RE ]]; then + echo "::error::version '${version}' does not match the required X.Y.Z-rigel.N shape" >&2 + return 1 + fi + base="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + while IFS= read -r tag; do + [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rigel\.([1-9][0-9]*)$ ]] || continue + [ "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" = "$base" ] || continue + if [ -z "$max" ] || compare_decimal "${BASH_REMATCH[4]}" "$max"; then + max="${BASH_REMATCH[4]}" + fi + done < <(git tag --list "v${base}-rigel.*") + if [ -z "$max" ]; then + expected="${base}-rigel.1" + else + expected="${base}-rigel.$(increment_decimal "$max")" + fi + if [ "$version" != "$expected" ]; then + echo "::error::expected ${expected}, got ${version}" >&2 + return 1 + fi +} + +check_pr() { + local base_sha="$1" head_sha="$2" base_version head_version + if git diff --quiet "$base_sha" "$head_sha" -- .github/rigel-version; then + echo '.github/rigel-version is unchanged; no release version check is needed' + return 0 + fi + base_version="$(version_at "$base_sha" no)" + head_version="$(version_at "$head_sha")" + if [ "$base_version" = "$head_version" ]; then + echo '.github/rigel-version version is unchanged; no release check is needed' + return 0 + fi + validate_next_version "$head_version" + echo "${head_version} is the next release version" +} + +mint() { + local sha="$1" repo="$2" version tag target parent parent_version response rc actual + version="$(version_at "$sha")" + tag="v${version}" + if git show-ref --verify --quiet "refs/tags/${tag}" || gh api "repos/${repo}/git/ref/tags/${tag}" >/dev/null 2>&1; then + echo "${tag} already exists; nothing to mint" + return 0 + fi + + target="$sha" + while parent="$(git rev-parse --verify "${target}^1" 2>/dev/null)"; do + parent_version="$(version_at "$parent" no)" + [ "$parent_version" = "$version" ] || break + target="$parent" + done + validate_next_version "$version" + + set +e + response="$(gh api --method POST "repos/${repo}/git/refs" -f "ref=refs/tags/${tag}" -f "sha=${target}" 2>&1)" + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "Created ${tag} at ${target}" + return 0 + fi + if [[ "$response" == *'HTTP 422'* && "$response" == *'Reference already exists'* ]]; then + actual="$(gh api "repos/${repo}/git/ref/tags/${tag}" --jq '.object.sha')" + if [ "$actual" = "$target" ]; then + echo "${tag} already points at ${target}" + return 0 + fi + echo "::error::${tag} already exists at ${actual}, expected ${target}" >&2 + return 1 + fi + printf '%s\n' "$response" >&2 + return "$rc" +} + +[ "$#" -ge 1 ] || usage +command="$1" +shift +case "$command" in + check-pr) + [ "$#" -eq 2 ] || usage + check_pr "$1" "$2" + ;; + mint) + [ "$#" -eq 2 ] || usage + mint "$1" "$2" + ;; + *) usage ;; +esac diff --git a/.github/scripts/release-tag.test.sh b/.github/scripts/release-tag.test.sh new file mode 100755 index 0000000..1c79e21 --- /dev/null +++ b/.github/scripts/release-tag.test.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Check version selection, deterministic mint targets, and collisions. +set -u +script="$(cd "$(dirname "$0")" && pwd)/release-tag.sh" +dir="$(mktemp -d)" +trap 'rm -rf "$dir"' EXIT +fails=0 +mkdir -p "$dir/bin" "$dir/repo" +cat >"$dir/bin/gh" <<'EOF' +#!/usr/bin/env bash +case "$*" in + *"--method POST"*) + printf '%s\n' "$*" >>"$GH_LOG" + if [ "${GH_MODE:-create}" = collision ]; then + : >"$GH_MARKER" + echo 'Reference already exists' >&2 + echo 'HTTP 422' >&2 + exit 1 + fi + ;; + *"--method DELETE"*) + printf '%s\n' "$*" >>"$GH_LOG" + ;; + *"git/ref/tags/"*) + if [ "${GH_MODE:-create}" = existing ]; then exit 0; fi + if [ "${GH_MODE:-create}" = collision ] && [ -e "$GH_MARKER" ]; then + printf '%s\n' "$GH_EXISTING_SHA" + exit 0 + fi + exit 1 + ;; + *) echo "unexpected gh call: $*" >&2; exit 2 ;; +esac +EOF +chmod +x "$dir/bin/gh" +export PATH="$dir/bin:$PATH" +export GH_LOG="$dir/gh.log" +export GH_MARKER="$dir/gh-marker" +cd "$dir/repo" || exit 1 +git init -q -b rigel-release +git config user.name test +git config user.email test@example.invalid +write_version() { + mkdir -p .github + printf '%s\n' "$1" >.github/rigel-version + git add .github/rigel-version + git commit -q -m "$1" + git rev-parse HEAD +} +old="$(write_version 1.1.0-rigel.1)" +base="$old" +new="$(write_version 1.1.0-rigel.2)" +noop="$(write_version 1.1.0-rigel.2)" +git tag v1.1.0-rigel.1 "$old" + +expect_rc() { + local name="$1" want="$2"; shift 2 + local rc + "$@" >/dev/null 2>&1 + rc=$? + if [ "$rc" -ne "$want" ]; then + echo "FAIL ${name}: rc=${rc} (want ${want})" + fails=$((fails + 1)) + else + echo "ok ${name}" + fi +} +if "$script" check-pr "$base" "$new" >/dev/null 2>&1; then + echo 'ok next tracking version passes PR check' +else + echo 'FAIL next tracking version PR check' + fails=$((fails + 1)) +fi +skip="$(write_version 1.1.0-rigel.4)" +expect_rc 'skipped suffix fails PR check' 1 "$script" check-pr "$new" "$skip" +git checkout -q -b vendor-refresh "$new" +printf 'vendorHash = "refreshed";\n' >vendor.nix +git add vendor.nix +git commit -q -m vendor-refresh +noop="$(git rev-parse HEAD)" +expect_rc 'unchanged source passes PR check' 0 "$script" check-pr "$new" "$noop" + +if GH_MODE=create "$script" mint "$noop" RigelBuild/nix2container >/dev/null 2>&1 && grep -q 'refs/tags/v1.1.0-rigel.2' "$GH_LOG"; then + echo 'ok mint creates expected version ref' +else + echo 'FAIL mint creates expected version ref' + fails=$((fails + 1)) +fi +if grep -q "sha=${new}" "$GH_LOG"; then + echo 'ok mint targets the commit that set the version' +else + echo 'FAIL mint targets the commit that set the version' + fails=$((fails + 1)) +fi +before="$(wc -l <"$GH_LOG")" +if GH_MODE=existing "$script" mint "$noop" RigelBuild/nix2container >/dev/null 2>&1 && [ "$(wc -l <"$GH_LOG")" -eq "$before" ]; then + echo 'ok existing remote release tag is a no-op' +else + echo 'FAIL existing remote release tag is a no-op' + fails=$((fails + 1)) +fi +if GH_MODE=collision GH_EXISTING_SHA="$new" "$script" mint "$new" RigelBuild/nix2container >/dev/null 2>&1; then + echo 'ok same-target 422 succeeds' +else + echo 'FAIL same-target 422 succeeds' + fails=$((fails + 1)) +fi +rm -f "$GH_MARKER" +if GH_MODE=collision GH_EXISTING_SHA="$old" "$script" mint "$new" RigelBuild/nix2container >/dev/null 2>&1; then + echo 'FAIL conflicting 422 fails' + fails=$((fails + 1)) +else + echo 'ok conflicting 422 fails' +fi + +[ "$fails" -eq 0 ] diff --git a/.github/workflows/golang.yml b/.github/workflows/golang.yml index db17d7c..5d2c1b7 100644 --- a/.github/workflows/golang.yml +++ b/.github/workflows/golang.yml @@ -14,10 +14,17 @@ jobs: uses: actions/checkout@v2 - name: Test run: go test ./... + release-tag-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Test release tag script + run: bash .github/scripts/release-tag.test.sh CI: - needs: [test] + needs: [test, release-tag-test] # A skipped required check counts as passing, so always run and fail on a non-success test. if: ${{ always() }} runs-on: ubuntu-latest steps: - - run: test "${{ needs.test.result }}" = success + - run: test "${{ needs.test.result }}" = success && test "${{ needs.release-tag-test.result }}" = success diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml new file mode 100644 index 0000000..ec28b5b --- /dev/null +++ b/.github/workflows/release-tag.yaml @@ -0,0 +1,63 @@ +name: release-tag + +'on': + pull_request: + branches: [rigel-release] + push: + branches: [rigel-release] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Validate a changed release version + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: .github/scripts/release-tag.sh check-pr "$BASE_SHA" "$HEAD_SHA" + + mint: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + environment: rigel-release + steps: + - name: Require release-branch dispatch + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/rigel-release' + run: | + echo '::error::workflow_dispatch is only allowed from rigel-release' >&2 + exit 1 + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Create scoped release App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.AUTOMATION_APP_CLIENT_ID }} + private-key: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }} + permission-contents: write + repositories: nix2container + + - name: Mint release tag if needed + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_REPOSITORY: ${{ github.repository }} + run: .github/scripts/release-tag.sh mint "$RELEASE_SHA" "$RELEASE_REPOSITORY" From 89cf1fa67673201c6399cf166e48dca8ce0f2892 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 21:32:23 -0400 Subject: [PATCH 2/4] ci(release): mint on dispatch; cover first version and merge target (RIG-5346) Co-authored-by: Matt Wilkinson --- .github/scripts/release-tag.test.sh | 33 +++++++++++++++++++++++++++++ .github/workflows/release-tag.yaml | 1 - 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-tag.test.sh b/.github/scripts/release-tag.test.sh index 1c79e21..aba3a38 100755 --- a/.github/scripts/release-tag.test.sh +++ b/.github/scripts/release-tag.test.sh @@ -71,6 +71,25 @@ else echo 'FAIL next tracking version PR check' fails=$((fails + 1)) fi +mkdir -p "$dir/no-version-repo" +cd "$dir/no-version-repo" || exit 1 +git init -q -b test +git config user.name test +git config user.email test@example.invalid +printf 'base\n' >README +git add README +git commit -q -m initial +missing_base="$(git rev-parse HEAD)" +added_initial="$(write_version 1.1.0-rigel.1)" +if "$script" check-pr "$missing_base" "$added_initial" >/dev/null 2>&1; then + echo 'ok missing version source accepts initial suffix' +else + echo 'FAIL missing version source accepts initial suffix' + fails=$((fails + 1)) +fi +skipped_initial="$(write_version 1.1.0-rigel.2)" +expect_rc 'missing version source rejects skipped initial suffix' 1 "$script" check-pr "$missing_base" "$skipped_initial" +cd "$dir/repo" || exit 1 skip="$(write_version 1.1.0-rigel.4)" expect_rc 'skipped suffix fails PR check' 1 "$script" check-pr "$new" "$skip" git checkout -q -b vendor-refresh "$new" @@ -92,6 +111,20 @@ else echo 'FAIL mint targets the commit that set the version' fails=$((fails + 1)) fi +git checkout -q -b side-version "$old" +different="$(write_version 1.1.0-rigel.3)" +git checkout -q vendor-refresh +git merge --no-ff --no-commit "$different" >/dev/null 2>&1 +printf '%s\n' 1.1.0-rigel.2 >.github/rigel-version +git add .github/rigel-version +git commit -q -m 'merge different version state' +merge="$(git rev-parse HEAD)" +if [ "$(git rev-parse "${merge}^1")" = "$noop" ] && [ "$(git rev-parse "${merge}^2")" = "$different" ] && [ "$(git show "${merge}^2:.github/rigel-version")" = 1.1.0-rigel.3 ] && GH_LOG="$dir/merge-gh.log" GH_MODE=create "$script" mint "$merge" RigelBuild/nix2container >/dev/null 2>&1 && grep -Fq "sha=${new}" "$dir/merge-gh.log"; then + echo 'ok merge mint targets version-setting commit on first-parent path' +else + echo 'FAIL merge mint targets version-setting commit on first-parent path' + fails=$((fails + 1)) +fi before="$(wc -l <"$GH_LOG")" if GH_MODE=existing "$script" mint "$noop" RigelBuild/nix2container >/dev/null 2>&1 && [ "$(wc -l <"$GH_LOG")" -eq "$before" ]; then echo 'ok existing remote release tag is a no-op' diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml index ec28b5b..2d70813 100644 --- a/.github/workflows/release-tag.yaml +++ b/.github/workflows/release-tag.yaml @@ -55,7 +55,6 @@ jobs: repositories: nix2container - name: Mint release tag if needed - if: github.event_name == 'push' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} RELEASE_SHA: ${{ github.sha }} From 851db9153fec5fbe9bc39f9edb49bedaa3800293 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 21:36:34 -0400 Subject: [PATCH 3/4] test(release): numeric N and dispatch reaching mint (RIG-5346) Co-authored-by: Matt Wilkinson --- .github/scripts/release-tag.test.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/scripts/release-tag.test.sh b/.github/scripts/release-tag.test.sh index aba3a38..8bfed70 100755 --- a/.github/scripts/release-tag.test.sh +++ b/.github/scripts/release-tag.test.sh @@ -92,6 +92,20 @@ expect_rc 'missing version source rejects skipped initial suffix' 1 "$script" ch cd "$dir/repo" || exit 1 skip="$(write_version 1.1.0-rigel.4)" expect_rc 'skipped suffix fails PR check' 1 "$script" check-pr "$new" "$skip" +git tag v1.1.0-rigel.2 "$new" +git tag v1.1.0-rigel.9 "$new" +git tag v1.1.0-rigel.10 "$new" +git checkout -q -B suffix-sequence "$new" +eleven="$(write_version 1.1.0-rigel.11)" +if "$script" check-pr "$new" "$eleven" >/dev/null 2>&1; then + echo 'ok numeric suffix 11 follows tags 1, 2, 9, and 10' +else + echo 'FAIL numeric suffix 11 follows tags 1, 2, 9, and 10' + fails=$((fails + 1)) +fi +ten="$(write_version 1.1.0-rigel.10)" +expect_rc 'numeric suffix 10 fails after existing suffix 10' 1 "$script" check-pr "$new" "$ten" +git tag -d v1.1.0-rigel.2 v1.1.0-rigel.9 v1.1.0-rigel.10 >/dev/null git checkout -q -b vendor-refresh "$new" printf 'vendorHash = "refreshed";\n' >vendor.nix git add vendor.nix @@ -146,4 +160,15 @@ else echo 'ok conflicting 422 fails' fi +workflow="$(dirname "$script")/../workflows/release-tag.yaml" +# Dispatch must mint too; only the ref guard and the job-level PR split may carry an `if`. +if [ "$(yq -r '.jobs.mint.steps[] | select(.name == "Mint release tag if needed") | has("if")' "$workflow")" = false ] \ + && [ "$(yq -r '.jobs.mint.if' "$workflow")" = "github.event_name != 'pull_request'" ] \ + && [ "$(yq -r '.on | has("workflow_dispatch")' "$workflow")" = true ]; then + echo 'ok push and dispatch both reach the mint step' +else + echo 'FAIL push and dispatch both reach the mint step' + fails=$((fails + 1)) +fi + [ "$fails" -eq 0 ] From fff24ed31d227a3bbc5670951223b08c8741b0f3 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 21:37:57 -0400 Subject: [PATCH 4/4] test(release): pin the rigel-release push trigger (RIG-5346) Co-authored-by: Matt Wilkinson --- .github/scripts/release-tag.test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-tag.test.sh b/.github/scripts/release-tag.test.sh index 8bfed70..337db7c 100755 --- a/.github/scripts/release-tag.test.sh +++ b/.github/scripts/release-tag.test.sh @@ -164,7 +164,8 @@ workflow="$(dirname "$script")/../workflows/release-tag.yaml" # Dispatch must mint too; only the ref guard and the job-level PR split may carry an `if`. if [ "$(yq -r '.jobs.mint.steps[] | select(.name == "Mint release tag if needed") | has("if")' "$workflow")" = false ] \ && [ "$(yq -r '.jobs.mint.if' "$workflow")" = "github.event_name != 'pull_request'" ] \ - && [ "$(yq -r '.on | has("workflow_dispatch")' "$workflow")" = true ]; then + && [ "$(yq -r '.on | has("workflow_dispatch")' "$workflow")" = true ] \ + && [ "$(yq -r '.on.push.branches | join(",")' "$workflow")" = rigel-release ]; then echo 'ok push and dispatch both reach the mint step' else echo 'FAIL push and dispatch both reach the mint step'