Skip to content

Commit 0c1deab

Browse files
authored
ci: guard release tags against stale source (#652)
1 parent 1666262 commit 0c1deab

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
cat <<'USAGE'
6+
Usage: verify-release-source.sh [--remote-ref=<ref>] [--tag=<tag>] [--expected-source=<sha>]
7+
8+
Verifies release source freshness for the Data Machine Code release workflow.
9+
Without --tag, the current checkout must match the remote main ref exactly.
10+
With --tag, the tag commit must contain --expected-source or the remote main ref.
11+
USAGE
12+
}
13+
14+
REMOTE_REF="origin/main"
15+
TAG_NAME=""
16+
EXPECTED_SOURCE=""
17+
18+
for arg in "$@"; do
19+
case "$arg" in
20+
--remote-ref=*)
21+
REMOTE_REF="${arg#--remote-ref=}"
22+
;;
23+
--tag=*)
24+
TAG_NAME="${arg#--tag=}"
25+
;;
26+
--expected-source=*)
27+
EXPECTED_SOURCE="${arg#--expected-source=}"
28+
;;
29+
-h|--help)
30+
usage
31+
exit 0
32+
;;
33+
*)
34+
echo "Unknown argument: $arg" >&2
35+
usage >&2
36+
exit 2
37+
;;
38+
esac
39+
done
40+
41+
REMOTE_NAME="${REMOTE_REF%%/*}"
42+
REMOTE_BRANCH="${REMOTE_REF#*/}"
43+
44+
if [ -z "$REMOTE_NAME" ] || [ "$REMOTE_NAME" = "$REMOTE_REF" ] || [ -z "$REMOTE_BRANCH" ]; then
45+
echo "::error::Remote ref must look like origin/main; got ${REMOTE_REF}" >&2
46+
exit 2
47+
fi
48+
49+
git fetch --tags "$REMOTE_NAME" "$REMOTE_BRANCH"
50+
51+
REMOTE_SHA="$(git rev-parse "$REMOTE_REF")"
52+
HEAD_SHA="$(git rev-parse HEAD)"
53+
54+
echo "Release source guard: checkout HEAD ${HEAD_SHA}"
55+
echo "Release source guard: ${REMOTE_REF} ${REMOTE_SHA}"
56+
57+
if [ -z "$TAG_NAME" ]; then
58+
read -r HEAD_ONLY REMOTE_ONLY < <(git rev-list --left-right --count "HEAD...${REMOTE_REF}")
59+
echo "Release source guard: HEAD...${REMOTE_REF} ahead=${HEAD_ONLY} behind=${REMOTE_ONLY}"
60+
61+
if [ "$HEAD_SHA" != "$REMOTE_SHA" ]; then
62+
echo "::error::Release checkout is not at ${REMOTE_REF}. HEAD=${HEAD_SHA} ${REMOTE_REF}=${REMOTE_SHA} ahead=${HEAD_ONLY} behind=${REMOTE_ONLY}. Fetch latest main and release from the current source before tagging." >&2
63+
exit 1
64+
fi
65+
66+
echo "Release source guard: checkout is current with ${REMOTE_REF}."
67+
if [ -n "${GITHUB_OUTPUT:-}" ]; then
68+
echo "source-sha=${REMOTE_SHA}" >> "$GITHUB_OUTPUT"
69+
fi
70+
exit 0
71+
fi
72+
73+
if ! git rev-parse --verify --quiet "${TAG_NAME}^{commit}" >/dev/null; then
74+
echo "::error::Release tag ${TAG_NAME} was not found after release. Expected tag to verify source ancestry." >&2
75+
exit 1
76+
fi
77+
78+
TAG_SHA="$(git rev-parse "${TAG_NAME}^{commit}")"
79+
SOURCE_SHA="${EXPECTED_SOURCE:-$REMOTE_SHA}"
80+
SOURCE_SUBJECT="$(git log -1 --format=%s "$SOURCE_SHA")"
81+
TAG_SUBJECT="$(git log -1 --format=%s "$TAG_SHA")"
82+
REMOTE_SUBJECT="$(git log -1 --format=%s "$REMOTE_SHA")"
83+
read -r TAG_ONLY REMOTE_ONLY < <(git rev-list --left-right --count "${TAG_NAME}...${REMOTE_REF}")
84+
read -r TAG_SOURCE_ONLY SOURCE_ONLY < <(git rev-list --left-right --count "${TAG_NAME}...${SOURCE_SHA}")
85+
86+
echo "Release source guard: tag ${TAG_NAME} ${TAG_SHA}"
87+
echo "Release source guard: tag subject ${TAG_SUBJECT}"
88+
echo "Release source guard: expected source ${SOURCE_SHA}"
89+
echo "Release source guard: expected source subject ${SOURCE_SUBJECT}"
90+
echo "Release source guard: ${REMOTE_REF} subject ${REMOTE_SUBJECT}"
91+
echo "Release source guard: ${TAG_NAME}...${REMOTE_REF} ahead=${TAG_ONLY} behind=${REMOTE_ONLY}"
92+
echo "Release source guard: ${TAG_NAME}...${SOURCE_SHA} ahead=${TAG_SOURCE_ONLY} behind=${SOURCE_ONLY}"
93+
94+
if ! git merge-base --is-ancestor "$SOURCE_SHA" "$TAG_SHA"; then
95+
echo "::error::Release tag ${TAG_NAME} (${TAG_SHA}) does not contain expected source ${SOURCE_SHA}. Tag/source divergence: ahead=${TAG_SOURCE_ONLY} behind=${SOURCE_ONLY}. Latest ${REMOTE_REF}=${REMOTE_SHA}; tag/latest-main divergence: ahead=${TAG_ONLY} behind=${REMOTE_ONLY}. The tag may report a new version while missing merged fixes; fetch latest main and retag from current source." >&2
96+
exit 1
97+
fi
98+
99+
echo "Release source guard: tag ${TAG_NAME} contains expected source ${SOURCE_SHA}."

.github/workflows/release.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,21 @@ jobs:
103103
persist-credentials: false
104104
token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
105105

106+
- name: Verify release checkout is current
107+
id: source-guard
108+
run: bash .github/scripts/verify-release-source.sh
109+
106110
- uses: Extra-Chill/homeboy-action@v2
107111
id: release
108112
with:
109113
commands: release
110114
release-dry-run: ${{ inputs.dry-run || 'false' }}
111115
app-token: ${{ steps.app-token.outputs.token || '' }}
112116

117+
- name: Verify release tag contains current source
118+
if: ${{ steps.release.outputs.release-version != '' && (github.event_name != 'workflow_dispatch' || inputs.dry-run != true) }}
119+
run: bash .github/scripts/verify-release-source.sh --tag="v${{ steps.release.outputs.release-version }}" --expected-source="${{ steps.source-guard.outputs.source-sha }}"
120+
113121
# ── Record failed SHA to prevent retry loops ──
114122
record-failure:
115123
name: Record failure
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
GUARD="${ROOT_DIR}/.github/scripts/verify-release-source.sh"
6+
TMP_DIR="$(mktemp -d)"
7+
trap 'rm -rf "${TMP_DIR}"' EXIT
8+
9+
pass_count=0
10+
fail() {
11+
echo "not ok - $1" >&2
12+
exit 1
13+
}
14+
15+
pass() {
16+
pass_count=$((pass_count + 1))
17+
echo "ok ${pass_count} - $1"
18+
}
19+
20+
git_init_repo() {
21+
local repo="$1"
22+
git -C "$repo" config user.email smoke@example.com
23+
git -C "$repo" config user.name "Smoke Test"
24+
}
25+
26+
REMOTE="${TMP_DIR}/remote.git"
27+
WORK="${TMP_DIR}/work"
28+
STALE="${TMP_DIR}/stale"
29+
30+
git init -q --bare -b main "$REMOTE"
31+
git clone -q "$REMOTE" "$WORK" 2>/dev/null
32+
git_init_repo "$WORK"
33+
34+
printf 'first\n' > "${WORK}/source.txt"
35+
git -C "$WORK" add source.txt
36+
git -C "$WORK" commit -q -m 'fix: first releasable change'
37+
git -C "$WORK" push -q origin main
38+
FIRST_SHA="$(git -C "$WORK" rev-parse HEAD)"
39+
40+
git clone -q "$REMOTE" "$STALE"
41+
42+
printf 'second\n' > "${WORK}/source.txt"
43+
git -C "$WORK" commit -q -am 'fix: intended merged change'
44+
git -C "$WORK" push -q origin main
45+
SECOND_SHA="$(git -C "$WORK" rev-parse HEAD)"
46+
47+
if ( cd "$STALE" && bash "$GUARD" --remote-ref=origin/main ) >"${TMP_DIR}/stale-checkout.out" 2>"${TMP_DIR}/stale-checkout.err"; then
48+
fail 'stale checkout guard should fail'
49+
fi
50+
51+
if ! grep -q "HEAD=${FIRST_SHA}" "${TMP_DIR}/stale-checkout.err"; then
52+
fail 'stale checkout error includes HEAD SHA'
53+
fi
54+
55+
if ! grep -q "origin/main=${SECOND_SHA}" "${TMP_DIR}/stale-checkout.err"; then
56+
fail 'stale checkout error includes origin/main SHA'
57+
fi
58+
59+
if ! grep -q 'behind=1' "${TMP_DIR}/stale-checkout.err"; then
60+
fail 'stale checkout error includes behind count'
61+
fi
62+
63+
pass 'stale checkout fails with actionable SHA and behind evidence'
64+
65+
git -C "$STALE" fetch -q origin main --tags
66+
git -C "$STALE" reset -q --hard origin/main
67+
git -C "$STALE" tag v1.0.0
68+
69+
( cd "$STALE" && bash "$GUARD" --remote-ref=origin/main --tag=v1.0.0 --expected-source="$SECOND_SHA" ) >"${TMP_DIR}/fresh-tag.out" 2>"${TMP_DIR}/fresh-tag.err"
70+
71+
if ! grep -q "tag v1.0.0 ${SECOND_SHA}" "${TMP_DIR}/fresh-tag.out"; then
72+
fail 'fresh tag output includes tag SHA'
73+
fi
74+
75+
pass 'fresh tag containing origin/main passes with tag SHA evidence'
76+
77+
git -C "$STALE" tag -f v0.9.0 "$FIRST_SHA" >/dev/null
78+
79+
if ( cd "$STALE" && bash "$GUARD" --remote-ref=origin/main --tag=v0.9.0 --expected-source="$SECOND_SHA" ) >"${TMP_DIR}/stale-tag.out" 2>"${TMP_DIR}/stale-tag.err"; then
80+
fail 'stale tag guard should fail'
81+
fi
82+
83+
if ! grep -q "v0.9.0 (${FIRST_SHA})" "${TMP_DIR}/stale-tag.err"; then
84+
fail 'stale tag error includes tag SHA'
85+
fi
86+
87+
if ! grep -q "expected source ${SECOND_SHA}" "${TMP_DIR}/stale-tag.err"; then
88+
fail 'stale tag error includes expected source SHA'
89+
fi
90+
91+
if ! grep -q 'behind=1' "${TMP_DIR}/stale-tag.err"; then
92+
fail 'stale tag error includes behind count'
93+
fi
94+
95+
pass 'stale tag fails with tag SHA, origin/main SHA, and behind evidence'
96+
97+
echo "${pass_count}/${pass_count} passed"

0 commit comments

Comments
 (0)