From fdf2cfc44e8072a8e1ea59a45c5a79b40f24e7b8 Mon Sep 17 00:00:00 2001 From: Lia Date: Thu, 17 Sep 2026 04:49:15 +0000 Subject: [PATCH 1/2] ci: Fix Release Version Resolution for Untagged and Resumed Runs The release workflow resolved its version in one inline shell block under `set -euo pipefail`, where two paths could not succeed. Filtering tags through `grep` made a no-match fatal. On the ordinary untagged tip of `main`, `git tag --points-at HEAD | grep -E '^v[0-9]+...'` exits 1, and the step died before reaching its skip handling or `next-release-version.sh`, so a deployable commit could not obtain a release version (#228). Selecting stable tags now reads exit 1 as an empty answer while exit 2 and above still fail the release, which also lets the missing-previous-tag case report its own error. The rerun-resume path then rejected the tag it had itself chosen. With a stable tag already pointing at `HEAD` and no release published, the version comes from that tag, and the following existence check failed merely because the ref existed (#229). It now compares the tag's commit against the release commit, so only a tag on some other commit is a collision; `Create tag` already tolerates a tag that exists. The block moved into `.github/scripts/resolve-release-version.sh`, beside the `next-release-version.sh` it calls, so `tests/release-version-resolution.sh` can cover every path: automatic, resumed, skipped, dispatched, pushed-tag, and the runs that must be refused, each against a throwaway repository with a stubbed `gh`. --- .github/scripts/resolve-release-version.sh | 189 ++++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 138 +---------- tests/release-version-resolution.sh | 252 +++++++++++++++++++++ 4 files changed, 449 insertions(+), 133 deletions(-) create mode 100755 .github/scripts/resolve-release-version.sh create mode 100755 tests/release-version-resolution.sh diff --git a/.github/scripts/resolve-release-version.sh b/.github/scripts/resolve-release-version.sh new file mode 100755 index 00000000..578f21ff --- /dev/null +++ b/.github/scripts/resolve-release-version.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash + +# Resolves which version a release run publishes, or decides that it publishes +# nothing, and records the decision in $GITHUB_OUTPUT. Extracted from +# .github/workflows/release.yml so the four entry paths — an automatic release +# after CI, a rerun resuming a release whose tag was already cut, a dispatch, +# and a pushed tag — are covered by tests/release-version-resolution.sh. +# +# Inputs arrive as environment variables, mirroring the workflow's env block: +# +# EVENT_NAME github.event_name +# HEAD_SHA github.event.workflow_run.head_sha +# INPUT_VERSION github.event.inputs.version +# INPUT_DRAFT github.event.inputs.draft +# REF_NAME github.ref_name +# REF_TYPE github.ref_type +# GH_TOKEN a token `gh release view` can read releases with +# +# Outputs: skip, and for a real release version, app_version, chart_version, +# prerelease, latest, draft. Run from the repository root; the chart fields are +# read from helm/codeapi/Chart.yaml relative to it. + +set -euo pipefail + +EVENT_NAME="${EVENT_NAME:-}" +HEAD_SHA="${HEAD_SHA:-}" +INPUT_VERSION="${INPUT_VERSION:-}" +INPUT_DRAFT="${INPUT_DRAFT:-}" +REF_NAME="${REF_NAME:-}" +REF_TYPE="${REF_TYPE:-}" +GITHUB_OUTPUT="${GITHUB_OUTPUT:?GITHUB_OUTPUT must name the step output file}" + +STABLE_TAG_PATTERN='^v[0-9]+[.][0-9]+[.][0-9]+$' + +# `grep` exits 1 when nothing matches, and under `pipefail` that would abort the +# step. A commit with no stable tag is the ordinary state of `main`, so a +# no-match reads as an empty answer while a genuine grep failure — exit 2 and +# above — still fails the release. +select_stable_tags() { + local status=0 + grep -E "$STABLE_TAG_PATTERN" || status=$? + [ "$status" -le 1 ] +} + +# Highest stable tag among those `git tag` selects, empty when there are none. +newest_stable_tag() { + git tag "$@" | select_stable_tags | sort -V | tail -n 1 +} + +# The commit a tag resolves to, empty when the tag does not exist. +tag_commit() { + git rev-parse -q --verify "refs/tags/$1^{commit}" || true +} + +SKIP=false +VERSION="" +HEAD_COMMIT="" + +if [ "$EVENT_NAME" = "workflow_run" ]; then + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$HEAD_COMMIT" != "$HEAD_SHA" ]; then + echo "::error::Checked out SHA does not match the successful CI run" + exit 1 + fi + + REMOTE_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ -z "$REMOTE_MAIN_SHA" ]; then + echo "::error::Could not resolve the current main branch tip" + exit 1 + fi + if [ "$REMOTE_MAIN_SHA" != "$HEAD_SHA" ]; then + echo "main advanced after this CI run; the newer successful run will release the combined changes" + SKIP=true + fi + + # A rerun after tag creation but before release publication resumes the + # missing release rather than incrementing the version again. + EXACT_TAG="$(newest_stable_tag --points-at HEAD)" + if [ "$SKIP" = "false" ] && [ -n "$EXACT_TAG" ]; then + if gh release view "$EXACT_TAG" >/dev/null 2>&1; then + echo "$EXACT_TAG already publishes this commit; nothing to do" + SKIP=true + else + VERSION="$EXACT_TAG" + fi + elif [ "$SKIP" = "false" ]; then + PREVIOUS_TAG="$(newest_stable_tag --merged HEAD)" + if [ -z "$PREVIOUS_TAG" ]; then + echo "::error::Automatic releases require an existing stable vMAJOR.MINOR.PATCH tag" + exit 1 + fi + VERSION="$(.github/scripts/next-release-version.sh "$PREVIOUS_TAG" "$PREVIOUS_TAG..HEAD")" + if [ -z "$VERSION" ]; then + echo "Only documentation, workflow, or test files changed since $PREVIOUS_TAG; no release needed" + SKIP=true + fi + fi +elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then + # Releases describe what shipped to main. Dispatching from a topic branch + # would tag a commit that is not on the release line. + if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "main" ]; then + echo "::error::Releases must be cut from main; this run is on '$REF_NAME'" + exit 1 + fi + VERSION="$INPUT_VERSION" +else + VERSION="$REF_NAME" +fi + +if [ "$SKIP" = "true" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# A bare "2.0.0" typed into the dispatch box is accepted; everything downstream +# works with the v-prefixed form the tag actually uses. +case "$VERSION" in + v*) ;; + *) VERSION="v$VERSION" ;; +esac + +if [[ ! "$VERSION" =~ ^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$ ]]; then + echo "::error::Release tags must be v.. or v..-rcN, for example v1.0.0 or v1.1.0-rc1 (got '$VERSION')" + exit 1 +fi + +if [ "$EVENT_NAME" = "workflow_run" ]; then + # A tag already pointing at this commit is the resumed release above, and the + # publish steps tolerate it. Only a tag on some other commit is a collision. + EXISTING_TAG_COMMIT="$(tag_commit "$VERSION")" + if [ -n "$EXISTING_TAG_COMMIT" ] && [ "$EXISTING_TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Calculated tag $VERSION already exists on a different commit" + exit 1 + fi +fi + +read_chart_field() { + grep -m1 "^$1:" helm/codeapi/Chart.yaml \ + | sed -E "s/^$1:[[:space:]]*//; s/[[:space:]]*#.*//; s/^[\"']//; s/[\"']\$//" +} +APP_VERSION="$(read_chart_field appVersion)" +CHART_VERSION="$(read_chart_field version)" + +if [ "$EVENT_NAME" = "workflow_dispatch" ] \ + && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "::error::Tag $VERSION already exists. Pick a new version, or delete the tag if it was cut in error." + exit 1 +fi + +case "$VERSION" in + *-rc*) PRERELEASE=true ;; + *) PRERELEASE=false ;; +esac + +# `latest` moves only when this is the highest stable version, so re-cutting an +# older patch cannot drag it backwards. The tag under dispatch does not exist +# yet, hence adding it to the comparison. +LATEST=false +if [ "$PRERELEASE" = "false" ]; then + HIGHEST_STABLE="$( + { + git tag --list 'v[0-9]*' + printf '%s\n' "$VERSION" + } \ + | select_stable_tags \ + | sort -V \ + | tail -n 1 + )" + if [ "$HIGHEST_STABLE" = "$VERSION" ]; then + LATEST=true + fi +fi + +DRAFT=false +if [ "$INPUT_DRAFT" = "true" ]; then + DRAFT=true +fi + +{ + echo "skip=false" + echo "version=$VERSION" + echo "app_version=$APP_VERSION" + echo "chart_version=$CHART_VERSION" + echo "prerelease=$PRERELEASE" + echo "latest=$LATEST" + echo "draft=$DRAFT" +} >> "$GITHUB_OUTPUT" + +echo "Releasing $VERSION (chart $CHART_VERSION, appVersion $APP_VERSION, prerelease=$PRERELEASE, latest=$LATEST, draft=$DRAFT)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7259ec87..b645f4b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: - name: Release versioning run: tests/release-versioning.sh + - name: Release version resolution + run: tests/release-version-resolution.sh + - name: Validate sandbox Dockerfiles run: | docker buildx build --check -f api/Dockerfile . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea8a5364..db059ead 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,139 +78,11 @@ jobs: REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - SKIP=false - if [ "$EVENT_NAME" = "workflow_run" ]; then - if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then - echo "::error::Checked out SHA does not match the successful CI run" - exit 1 - fi - - REMOTE_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" - if [ -z "$REMOTE_MAIN_SHA" ]; then - echo "::error::Could not resolve the current main branch tip" - exit 1 - fi - if [ "$REMOTE_MAIN_SHA" != "$HEAD_SHA" ]; then - echo "main advanced after this CI run; the newer successful run will release the combined changes" - SKIP=true - fi - - # A rerun after tag creation but before release publication resumes - # the missing release rather than incrementing the version again. - EXACT_TAG="$({ git tag --points-at HEAD || true; } | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | sort -V | tail -n 1)" - if [ "$SKIP" = "false" ] && [ -n "$EXACT_TAG" ]; then - if gh release view "$EXACT_TAG" >/dev/null 2>&1; then - echo "$EXACT_TAG already publishes this commit; nothing to do" - SKIP=true - else - VERSION="$EXACT_TAG" - fi - elif [ "$SKIP" = "false" ]; then - PREVIOUS_TAG="$(git tag --merged HEAD \ - | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' \ - | sort -V \ - | tail -n 1)" - if [ -z "$PREVIOUS_TAG" ]; then - echo "::error::Automatic releases require an existing stable vMAJOR.MINOR.PATCH tag" - exit 1 - fi - VERSION="$(.github/scripts/next-release-version.sh "$PREVIOUS_TAG" "$PREVIOUS_TAG..HEAD")" - if [ -z "$VERSION" ]; then - echo "Only documentation, workflow, or test files changed since $PREVIOUS_TAG; no release needed" - SKIP=true - fi - fi - elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then - # Releases describe what shipped to main. Dispatching from a topic - # branch would tag a commit that is not on the release line. - if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "main" ]; then - echo "::error::Releases must be cut from main; this run is on '$REF_NAME'" - exit 1 - fi - VERSION="$INPUT_VERSION" - else - VERSION="$REF_NAME" - fi - - if [ "$SKIP" = "true" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # A bare "2.0.0" typed into the dispatch box is accepted; everything - # downstream works with the v-prefixed form the tag actually uses. - case "$VERSION" in - v*) ;; - *) VERSION="v$VERSION" ;; - esac - - if [[ ! "$VERSION" =~ ^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$ ]]; then - echo "::error::Release tags must be v.. or v..-rcN, for example v1.0.0 or v1.1.0-rc1 (got '$VERSION')" - exit 1 - fi - - if [ "$EVENT_NAME" = "workflow_run" ] \ - && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo "::error::Calculated tag $VERSION already exists on a different commit" - exit 1 - fi - - read_chart_field() { - grep -m1 "^$1:" helm/codeapi/Chart.yaml \ - | sed -E "s/^$1:[[:space:]]*//; s/[[:space:]]*#.*//; s/^[\"']//; s/[\"']\$//" - } - APP_VERSION="$(read_chart_field appVersion)" - CHART_VERSION="$(read_chart_field version)" - - if [ "$EVENT_NAME" = "workflow_dispatch" ] \ - && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo "::error::Tag $VERSION already exists. Pick a new version, or delete the tag if it was cut in error." - exit 1 - fi - - case "$VERSION" in - *-rc*) PRERELEASE=true ;; - *) PRERELEASE=false ;; - esac - - # `latest` moves only when this is the highest stable version, so - # re-cutting an older patch cannot drag it backwards. The tag under - # dispatch does not exist yet, hence adding it to the comparison. - LATEST=false - if [ "$PRERELEASE" = "false" ]; then - HIGHEST_STABLE="$( - { - git tag --list 'v[0-9]*' - printf '%s\n' "$VERSION" - } \ - | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' \ - | sort -V \ - | tail -n 1 - )" - if [ "$HIGHEST_STABLE" = "$VERSION" ]; then - LATEST=true - fi - fi - - DRAFT=false - if [ "$INPUT_DRAFT" = "true" ]; then - DRAFT=true - fi - - { - echo "skip=false" - echo "version=$VERSION" - echo "app_version=$APP_VERSION" - echo "chart_version=$CHART_VERSION" - echo "prerelease=$PRERELEASE" - echo "latest=$LATEST" - echo "draft=$DRAFT" - } >> "$GITHUB_OUTPUT" - - echo "Releasing $VERSION (chart $CHART_VERSION, appVersion $APP_VERSION, prerelease=$PRERELEASE, latest=$LATEST, draft=$DRAFT)" + # The resolution itself lives in a script so that every path through it + # — automatic release, resumed release, dispatch, pushed tag, and the + # runs that must skip or fail — is covered by + # tests/release-version-resolution.sh in CI. + run: .github/scripts/resolve-release-version.sh # helm is preinstalled on ubuntu-latest, the same way the chart tests in # ci.yml depend on it. diff --git a/tests/release-version-resolution.sh b/tests/release-version-resolution.sh new file mode 100755 index 00000000..832ce92f --- /dev/null +++ b/tests/release-version-resolution.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash + +# Covers .github/scripts/resolve-release-version.sh: the version a release run +# publishes, and the runs that have to skip or fail instead. Every case builds a +# throwaway repository with an `origin` the resolver can query and a stubbed +# `gh`, so nothing here reaches the network or the real repository. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RESOLVER="$ROOT/.github/scripts/resolve-release-version.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +REPO="$WORK/repo" +ORIGIN="$WORK/origin.git" +OUTPUT="$WORK/github_output" +LOG="$WORK/log" +CASE='' +STATUS=0 +FAILURES=0 + +# `gh release view` is the only gh call the resolver makes. PUBLISHED lists the +# releases that already exist; anything else must not be invoked at all. +mkdir -p "$WORK/bin" +cat > "$WORK/bin/gh" <<'STUB' +#!/usr/bin/env bash +if [ "$1" = 'release' ] && [ "$2" = 'view' ]; then + for published in ${PUBLISHED:-}; do + if [ "$published" = "$3" ]; then + exit 0 + fi + done + exit 1 +fi +echo "unexpected gh invocation: $*" >&2 +exit 2 +STUB +chmod +x "$WORK/bin/gh" +PATH="$WORK/bin:$PATH" + +git_repo() { + git -C "$REPO" "$@" +} + +# A fresh repository whose layout matches what the resolver reads from the +# checkout: the version bump script it shells out to, and the chart it takes the +# app and chart versions from, trailing comments and quotes included. +new_case() { + CASE="$1" + rm -rf "$REPO" "$ORIGIN" + git init -q --bare "$ORIGIN" + git init -q -b main "$REPO" + git_repo config user.name test + git_repo config user.email test@example.com + git_repo remote add origin "$ORIGIN" + mkdir -p "$REPO/.github/scripts" "$REPO/helm/codeapi" + cp "$ROOT/.github/scripts/next-release-version.sh" "$REPO/.github/scripts/" + cat > "$REPO/helm/codeapi/Chart.yaml" <<'CHART' +apiVersion: v2 +name: codeapi +version: 0.3.1 # Chart version +appVersion: "2.0.0" # App version +CHART + commit api/runtime.ts initial 'chore: initial import' + publish_main +} + +commit() { + local path="$1" content="$2" message="$3" + mkdir -p "$REPO/$(dirname "$path")" + printf '%s\n' "$content" > "$REPO/$path" + git_repo add "$path" + git_repo commit -q -m "$message" +} + +publish_main() { + git_repo push -q origin main +} + +head_sha() { + git_repo rev-parse HEAD +} + +# Runs the resolver in the throwaway repository. Arguments are KEY=VALUE pairs +# standing in for the workflow's env block. +resolve() { + : > "$OUTPUT" + set +e + (cd "$REPO" && env GITHUB_OUTPUT="$OUTPUT" "$@" bash "$RESOLVER") > "$LOG" 2>&1 + STATUS=$? + set -e +} + +fail() { + echo "FAIL [$CASE] $1" >&2 + sed 's/^/ | /' "$LOG" >&2 + FAILURES=$((FAILURES + 1)) +} + +expect_status() { + if [ "$STATUS" != "$1" ]; then + fail "exit status: expected $1, got $STATUS" + fi +} + +expect_output() { + local actual + actual="$(sed -n "s/^$1=//p" "$OUTPUT" | tail -n 1)" + if [ "$actual" != "$2" ]; then + fail "output $1: expected '$2', got '$actual'" + fi +} + +expect_log() { + if ! grep -qF "$1" "$LOG"; then + fail "expected log to mention: $1" + fi +} + +# An untagged tip of main is the ordinary automatic-release path: the version +# comes from Conventional Commit intent since the last stable tag. Filtering +# tags with `grep` used to abort the step here, because no match under +# `pipefail` looks like a command failure. +new_case 'automatic release from an untagged commit' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 0 +expect_output skip false +expect_output version v1.2.4 +expect_output app_version 2.0.0 +expect_output chart_version 0.3.1 +expect_output prerelease false +expect_output latest true +expect_output draft false + +new_case 'documentation-only range releases nothing' +git_repo tag v1.2.3 +commit docs/guide.md docs 'docs: clarify deployment' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 0 +expect_output skip true +expect_output version '' +expect_log 'no release needed' + +new_case 'a repository without a stable tag reports why' +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 1 +expect_log 'Automatic releases require an existing stable' + +# The rerun-to-publish recovery path: a previous run created the tag and then +# failed before the release existed. +new_case 'a rerun resumes the tag that already points at HEAD' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +git_repo tag v1.2.4 +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" PUBLISHED='' +expect_status 0 +expect_output skip false +expect_output version v1.2.4 +expect_output latest true + +new_case 'a published tag at HEAD releases nothing twice' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +git_repo tag v1.2.4 +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" PUBLISHED='v1.2.4' +expect_status 0 +expect_output skip true +expect_log 'already publishes this commit' + +new_case 'a calculated tag held by another commit is a collision' +git_repo tag v1.2.3 +git_repo checkout -q -b elsewhere +commit api/runtime.ts diverged 'fix: unrelated work' +git_repo tag v1.2.4 +git_repo checkout -q main +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 1 +expect_log 'already exists on a different commit' + +new_case 'a stale CI run defers to the newer tip' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +commit api/runtime.ts advanced 'fix: land more work' +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 0 +expect_output skip true +expect_log 'main advanced after this CI run' + +new_case 'a dispatched version may omit the v prefix' +git_repo tag v1.2.3 +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=2.0.0 INPUT_DRAFT=true +expect_status 0 +expect_output version v2.0.0 +expect_output prerelease false +expect_output latest true +expect_output draft true + +new_case 'a release candidate is a prerelease and never latest' +git_repo tag v1.2.3 +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=v1.3.0-rc1 +expect_status 0 +expect_output version v1.3.0-rc1 +expect_output prerelease true +expect_output latest false + +new_case 'dispatching from a topic branch is refused' +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=feature/x \ + INPUT_VERSION=v1.3.0 +expect_status 1 +expect_log 'Releases must be cut from main' + +new_case 'dispatching an existing version is refused' +git_repo tag v1.2.3 +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=v1.2.3 +expect_status 1 +expect_log 'already exists' + +new_case 'a malformed version is refused' +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=1.2 +expect_status 1 +expect_log 'Release tags must be' + +new_case 'a pushed older patch tag does not become latest' +git_repo tag v9.9.9 +resolve EVENT_NAME=push REF_TYPE=tag REF_NAME=v1.0.1 +expect_status 0 +expect_output version v1.0.1 +expect_output prerelease false +expect_output latest false + +if [ "$FAILURES" -ne 0 ]; then + echo "$FAILURES release version resolution assertion(s) failed" >&2 + exit 1 +fi + +echo 'release version resolution tests passed' From d545362ebea98738e9334292a956ffeb829d7886 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 17 Sep 2026 23:21:46 -0400 Subject: [PATCH 2/2] fix: harden release resolver execution --- .github/scripts/resolve-release-version.sh | 18 ++++++++++++------ .github/workflows/release.yml | 17 ++++++++++++++++- tests/release-version-resolution.sh | 13 +++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/.github/scripts/resolve-release-version.sh b/.github/scripts/resolve-release-version.sh index 578f21ff..ef5f8667 100755 --- a/.github/scripts/resolve-release-version.sh +++ b/.github/scripts/resolve-release-version.sh @@ -47,9 +47,10 @@ newest_stable_tag() { git tag "$@" | select_stable_tags | sort -V | tail -n 1 } -# The commit a tag resolves to, empty when the tag does not exist. +# The commit a tag resolves to. The caller first verifies that the ref exists, +# so a failure here means the tag ultimately names a non-commit object. tag_commit() { - git rev-parse -q --verify "refs/tags/$1^{commit}" || true + git rev-parse -q --verify "refs/tags/$1^{commit}" } SKIP=false @@ -127,10 +128,15 @@ fi if [ "$EVENT_NAME" = "workflow_run" ]; then # A tag already pointing at this commit is the resumed release above, and the # publish steps tolerate it. Only a tag on some other commit is a collision. - EXISTING_TAG_COMMIT="$(tag_commit "$VERSION")" - if [ -n "$EXISTING_TAG_COMMIT" ] && [ "$EXISTING_TAG_COMMIT" != "$HEAD_COMMIT" ]; then - echo "::error::Calculated tag $VERSION already exists on a different commit" - exit 1 + if git show-ref --verify --quiet "refs/tags/$VERSION"; then + if ! EXISTING_TAG_COMMIT="$(tag_commit "$VERSION")"; then + echo "::error::Calculated tag $VERSION already exists but does not point to a commit" + exit 1 + fi + if [ "$EXISTING_TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Calculated tag $VERSION already exists on a different commit" + exit 1 + fi fi fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db059ead..684f1654 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # `workflow_run` can be rerun for a commit older than this resolver. Save + # the helper from the revision that supplied this workflow before the + # release checkout replaces the working tree with that historical SHA. + - name: Checkout release workflow + if: github.event_name == 'workflow_run' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + + - name: Preserve release resolver + if: github.event_name == 'workflow_run' + run: install -m 755 .github/scripts/resolve-release-version.sh "$RUNNER_TEMP/resolve-release-version.sh" + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: # Full history and tags: resolving whether this release is the newest @@ -78,11 +92,12 @@ jobs: REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} GH_TOKEN: ${{ github.token }} + RESOLVER_PATH: ${{ github.event_name == 'workflow_run' && format('{0}/resolve-release-version.sh', runner.temp) || '.github/scripts/resolve-release-version.sh' }} # The resolution itself lives in a script so that every path through it # — automatic release, resumed release, dispatch, pushed tag, and the # runs that must skip or fail — is covered by # tests/release-version-resolution.sh in CI. - run: .github/scripts/resolve-release-version.sh + run: "$RESOLVER_PATH" # helm is preinstalled on ubuntu-latest, the same way the chart tests in # ci.yml depend on it. diff --git a/tests/release-version-resolution.sh b/tests/release-version-resolution.sh index 832ce92f..c70d50d3 100755 --- a/tests/release-version-resolution.sh +++ b/tests/release-version-resolution.sh @@ -8,6 +8,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# The resolver is deliberately kept outside each throwaway checkout. That +# mirrors release.yml preserving the workflow revision in RUNNER_TEMP before a +# workflow_run checks out the possibly historical release commit. RESOLVER="$ROOT/.github/scripts/resolve-release-version.sh" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT @@ -188,6 +191,16 @@ resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" expect_status 1 expect_log 'already exists on a different commit' +new_case 'a calculated tag held by a non-commit object is a collision' +git_repo tag v1.2.3 +blob="$(printf 'not a commit\n' | git_repo hash-object -w --stdin)" +git_repo update-ref refs/tags/v1.2.4 "$blob" +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 1 +expect_log 'already exists but does not point to a commit' + new_case 'a stale CI run defers to the newer tip' git_repo tag v1.2.3 commit api/runtime.ts repaired 'fix: repair execution'