Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/rigel-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.1.0-rigel.1
142 changes: 142 additions & 0 deletions .github/scripts/release-tag.sh
Original file line number Diff line number Diff line change
@@ -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 <base-sha> <head-sha> | mint <sha> <owner/repo>' >&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
175 changes: 175 additions & 0 deletions .github/scripts/release-tag.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/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
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 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
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
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'
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

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 ] \
&& [ "$(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'
fails=$((fails + 1))
fi

[ "$fails" -eq 0 ]
11 changes: 9 additions & 2 deletions .github/workflows/golang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading