Skip to content

Commit 23751c2

Browse files
committed
Reuse safe local release tags
1 parent 99c9fda commit 23751c2

3 files changed

Lines changed: 117 additions & 7 deletions

File tree

docs/npm-publishing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ bun run release:github -- --dry-run # plan only
104104
bun run release:github -- --yes # tag, git push --atomic tags, gh release create ×2
105105
```
106106

107-
Requires [**GitHub CLI**](https://cli.github.com/) (`gh`) authenticated (`gh auth login`). NPM publish still runs in Actions when each release is **published**.
107+
Requires [**GitHub CLI**](https://cli.github.com/) (`gh`) authenticated (`gh auth login`). If a transient push failure leaves local annotated tags at the release commit, the helper reuses them on retry; local lightweight tags or tags pointing elsewhere fail closed. NPM publish still runs in Actions when each release is **published**.
108108

109109
## Verify locally before tagging
110110

scripts/release/gh-release.sh

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,32 @@ github_release_exists() {
123123
fail "could not check GitHub release $tag (exit $status)"
124124
}
125125

126+
local_release_tag_ready() {
127+
local tag="$1"
128+
local expected_sha="$2"
129+
130+
if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
131+
return 1
132+
fi
133+
134+
local tag_type
135+
tag_type="$(git cat-file -t "refs/tags/$tag")" ||
136+
fail "could not inspect local tag type for $tag"
137+
if [ "$tag_type" != "tag" ]; then
138+
fail "local tag $tag already exists but is not annotated; delete it before releasing"
139+
fi
140+
141+
local tag_target
142+
tag_target="$(git rev-parse "$tag^{}")" ||
143+
fail "could not resolve local tag target for $tag"
144+
if [ "$tag_target" != "$expected_sha" ]; then
145+
fail "local tag $tag points at $tag_target, expected $expected_sha"
146+
fi
147+
148+
echo "Reusing local annotated tag $tag at $expected_sha"
149+
return 0
150+
}
151+
126152
usage() {
127153
sed -n '1,80p' <<'EOF'
128154
Usage: bash scripts/release/gh-release.sh [--dry-run | --yes]
@@ -203,9 +229,15 @@ fi
203229

204230
run_required_bounded "checking GitHub CLI authentication" 60 gh auth status >/dev/null
205231

232+
CREATE_TAG_CORE=true
233+
CREATE_TAG_SDK=true
206234
for tag in "$TAG_CORE" "$TAG_SDK"; do
207-
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
208-
fail "local tag already exists: $tag"
235+
if local_release_tag_ready "$tag" "$LOCAL_HEAD"; then
236+
if [ "$tag" = "$TAG_CORE" ]; then
237+
CREATE_TAG_CORE=false
238+
else
239+
CREATE_TAG_SDK=false
240+
fi
209241
fi
210242
if remote_tag_exists "$tag"; then
211243
fail "remote tag already exists: $tag"
@@ -215,8 +247,12 @@ for tag in "$TAG_CORE" "$TAG_SDK"; do
215247
fi
216248
done
217249

218-
git tag -a "$TAG_CORE" -m "Release $TAG_CORE (@razroo/ray-core v$VER)"
219-
git tag -a "$TAG_SDK" -m "Release $TAG_SDK (@razroo/ray-sdk v$VER)"
250+
if [ "$CREATE_TAG_CORE" = true ]; then
251+
git tag -a "$TAG_CORE" -m "Release $TAG_CORE (@razroo/ray-core v$VER)"
252+
fi
253+
if [ "$CREATE_TAG_SDK" = true ]; then
254+
git tag -a "$TAG_SDK" -m "Release $TAG_SDK (@razroo/ray-sdk v$VER)"
255+
fi
220256
run_required_bounded "pushing release tags atomically" 120 git push --atomic origin "$TAG_CORE" "$TAG_SDK"
221257
run_required_bounded \
222258
"creating GitHub release $TAG_CORE" \

scripts/release/gh-release.test.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ async function writeExecutable(filePath: string, contents: string): Promise<void
2727

2828
async function createReleaseHelperFixture(
2929
t: TestContext,
30-
options: { ghReleaseViewScript: string },
30+
options: {
31+
ghReleaseViewScript: string;
32+
localTagMode?: "absent" | "annotated-at-head" | "lightweight-at-head" | "wrong-target";
33+
},
3134
): Promise<{ binDir: string; logPath: string; scriptPath: string; tempDir: string }> {
3235
const tempDir = await mkdtemp(path.join(tmpdir(), "ray-gh-release-helper-"));
3336
t.after(async () => {
@@ -40,6 +43,7 @@ async function createReleaseHelperFixture(
4043

4144
const binDir = path.join(tempDir, "bin");
4245
const logPath = path.join(tempDir, "commands.log");
46+
const localTagMode = options.localTagMode ?? "absent";
4347

4448
await writeExecutable(
4549
path.join(binDir, "timeout"),
@@ -80,6 +84,7 @@ async function createReleaseHelperFixture(
8084
[
8185
"#!/usr/bin/env bash",
8286
"set -euo pipefail",
87+
`LOCAL_TAG_MODE=${JSON.stringify(localTagMode)}`,
8388
'printf "git %s\\n" "$*" >> "${RAY_TEST_LOG:?}"',
8489
'if [ "${1:-}" = "status" ]; then',
8590
" exit 0",
@@ -100,7 +105,26 @@ async function createReleaseHelperFixture(
100105
" exit 0",
101106
"fi",
102107
'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "-q" ]; then',
103-
" exit 1",
108+
' if [ "$LOCAL_TAG_MODE" = "absent" ]; then',
109+
" exit 1",
110+
" fi",
111+
" exit 0",
112+
"fi",
113+
'if [ "${1:-}" = "cat-file" ] && [ "${2:-}" = "-t" ]; then',
114+
' if [ "$LOCAL_TAG_MODE" = "lightweight-at-head" ]; then',
115+
' echo "commit"',
116+
" else",
117+
' echo "tag"',
118+
" fi",
119+
" exit 0",
120+
"fi",
121+
'if [ "${1:-}" = "rev-parse" ] && [[ "${2:-}" == *"^{}" ]]; then',
122+
' if [ "$LOCAL_TAG_MODE" = "wrong-target" ]; then',
123+
' echo "deadbeef00000000"',
124+
" else",
125+
' echo "abcdef1234567890"',
126+
" fi",
127+
" exit 0",
104128
"fi",
105129
'if [ "${1:-}" = "ls-remote" ]; then',
106130
" exit 2",
@@ -218,6 +242,9 @@ test("gh release helper bounds network release operations", async () => {
218242
assert.match(contents, /git fetch --tags origin refs\/heads\/main:refs\/remotes\/origin\/main/);
219243
assert.match(contents, /run_bounded 60 git ls-remote --exit-code --tags origin/);
220244
assert.match(contents, /fail "could not check remote tag \$tag \(exit \$status\)"/);
245+
assert.match(contents, /local_release_tag_ready\(\) \{/);
246+
assert.match(contents, /git cat-file -t "refs\/tags\/\$tag"/);
247+
assert.match(contents, /git rev-parse "\$tag\^\{\}"/);
221248
assert.match(contents, /run_bounded 60 gh release view "\$tag"/);
222249
assert.match(contents, /fail "timed out checking GitHub release: \$tag"/);
223250
assert.match(
@@ -284,3 +311,50 @@ test("gh release helper continues when GitHub reports releases are missing", asy
284311
/^gh release create sdk-v1\.2\.3 --generate-notes --title sdk-v1\.2\.3$/m,
285312
);
286313
});
314+
315+
test("gh release helper reuses matching local annotated tags", async (t) => {
316+
const fixture = await createReleaseHelperFixture(t, {
317+
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),
318+
localTagMode: "annotated-at-head",
319+
});
320+
321+
const result = await runFixtureReleaseHelper(fixture);
322+
323+
assert.equal(result.code, 0);
324+
assert.match(result.stdout, /Reusing local annotated tag core-v1\.2\.3/);
325+
assert.match(result.stdout, /Reusing local annotated tag sdk-v1\.2\.3/);
326+
327+
const commandLog = await readFile(fixture.logPath, "utf8");
328+
assert.match(commandLog, /^git cat-file -t refs\/tags\/core-v1\.2\.3$/m);
329+
assert.match(commandLog, /^git rev-parse core-v1\.2\.3\^\{\}$/m);
330+
assert.doesNotMatch(commandLog, /^git tag -a /m);
331+
assert.match(commandLog, /^git push --atomic origin core-v1\.2\.3 sdk-v1\.2\.3$/m);
332+
});
333+
334+
test("gh release helper rejects unsafe existing local tags", async (t) => {
335+
const lightweightFixture = await createReleaseHelperFixture(t, {
336+
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),
337+
localTagMode: "lightweight-at-head",
338+
});
339+
340+
const lightweightResult = await runFixtureReleaseHelper(lightweightFixture);
341+
342+
assert.notEqual(lightweightResult.code, 0);
343+
assert.match(
344+
lightweightResult.stderr,
345+
/local tag core-v1\.2\.3 already exists but is not annotated/,
346+
);
347+
348+
const wrongTargetFixture = await createReleaseHelperFixture(t, {
349+
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),
350+
localTagMode: "wrong-target",
351+
});
352+
353+
const wrongTargetResult = await runFixtureReleaseHelper(wrongTargetFixture);
354+
355+
assert.notEqual(wrongTargetResult.code, 0);
356+
assert.match(
357+
wrongTargetResult.stderr,
358+
/local tag core-v1\.2\.3 points at deadbeef00000000, expected abcdef1234567890/,
359+
);
360+
});

0 commit comments

Comments
 (0)