Skip to content

Commit 1b0f36d

Browse files
committed
Resume safe GitHub releases
1 parent ccd939c commit 1b0f36d

3 files changed

Lines changed: 171 additions & 18 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`). 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**.
107+
Requires [**GitHub CLI**](https://cli.github.com/) (`gh`) authenticated (`gh auth login`). If a transient failure leaves local or remote annotated tags at the release commit, or creates one GitHub Release before the other, the helper reuses the safe pieces on retry and creates only what is still missing; local or remote lightweight tags, tags pointing elsewhere, and ambiguous release probes 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: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,20 +75,39 @@ run_required_bounded() {
7575
fail "${description} failed (exit $status)"
7676
}
7777

78-
remote_tag_exists() {
78+
remote_release_tag_ready() {
7979
local tag="$1"
80+
local expected_sha="$2"
81+
local output=""
8082
local status=0
81-
run_bounded 60 git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 ||
83+
output="$(
84+
run_bounded 60 git ls-remote --exit-code --tags origin "refs/tags/$tag" "refs/tags/$tag^{}" 2>/dev/null
85+
)" ||
8286
status=$?
8387

8488
if [ "$status" -eq 0 ]; then
89+
local peeled_target
90+
peeled_target="$(printf '%s\n' "$output" | awk -v ref="refs/tags/$tag^{}" '$2 == ref { print $1; exit }')"
91+
if [ -z "$peeled_target" ]; then
92+
fail "remote tag $tag already exists but is not an annotated tag or could not be peeled"
93+
fi
94+
95+
if [ "$peeled_target" != "$expected_sha" ]; then
96+
fail "remote tag $tag points at $peeled_target, expected $expected_sha"
97+
fi
98+
99+
echo "Reusing remote annotated tag $tag at $expected_sha"
85100
return 0
86101
fi
87102

88103
if [ "$status" -eq 2 ]; then
89104
return 1
90105
fi
91106

107+
if [ "$status" -eq 124 ]; then
108+
fail "timed out checking remote tag $tag"
109+
fi
110+
92111
fail "could not check remote tag $tag (exit $status)"
93112
}
94113

@@ -231,19 +250,43 @@ run_required_bounded "checking GitHub CLI authentication" 60 gh auth status >/de
231250

232251
CREATE_TAG_CORE=true
233252
CREATE_TAG_SDK=true
253+
PUSH_TAG_CORE=true
254+
PUSH_TAG_SDK=true
255+
CREATE_RELEASE_CORE=true
256+
CREATE_RELEASE_SDK=true
234257
for tag in "$TAG_CORE" "$TAG_SDK"; do
258+
local_ready=false
259+
remote_ready=false
235260
if local_release_tag_ready "$tag" "$LOCAL_HEAD"; then
261+
local_ready=true
262+
fi
263+
if remote_release_tag_ready "$tag" "$LOCAL_HEAD"; then
264+
remote_ready=true
265+
fi
266+
267+
if [ "$local_ready" = true ] || [ "$remote_ready" = true ]; then
236268
if [ "$tag" = "$TAG_CORE" ]; then
237269
CREATE_TAG_CORE=false
238270
else
239271
CREATE_TAG_SDK=false
240272
fi
241273
fi
242-
if remote_tag_exists "$tag"; then
243-
fail "remote tag already exists: $tag"
274+
275+
if [ "$remote_ready" = true ]; then
276+
if [ "$tag" = "$TAG_CORE" ]; then
277+
PUSH_TAG_CORE=false
278+
else
279+
PUSH_TAG_SDK=false
280+
fi
244281
fi
282+
245283
if github_release_exists "$tag"; then
246-
fail "GitHub release already exists: $tag"
284+
echo "Reusing GitHub release $tag"
285+
if [ "$tag" = "$TAG_CORE" ]; then
286+
CREATE_RELEASE_CORE=false
287+
else
288+
CREATE_RELEASE_SDK=false
289+
fi
247290
fi
248291
done
249292

@@ -253,13 +296,29 @@ fi
253296
if [ "$CREATE_TAG_SDK" = true ]; then
254297
git tag -a "$TAG_SDK" -m "Release $TAG_SDK (@razroo/ray-sdk v$VER)"
255298
fi
256-
run_required_bounded "pushing release tags atomically" 120 git push --atomic origin "$TAG_CORE" "$TAG_SDK"
257-
run_required_bounded \
258-
"creating GitHub release $TAG_CORE" \
259-
120 \
260-
gh release create "$TAG_CORE" --generate-notes --title "$TAG_CORE"
261-
run_required_bounded \
262-
"creating GitHub release $TAG_SDK" \
263-
120 \
264-
gh release create "$TAG_SDK" --generate-notes --title "$TAG_SDK"
299+
TAGS_TO_PUSH=()
300+
if [ "$PUSH_TAG_CORE" = true ]; then
301+
TAGS_TO_PUSH+=("$TAG_CORE")
302+
fi
303+
if [ "$PUSH_TAG_SDK" = true ]; then
304+
TAGS_TO_PUSH+=("$TAG_SDK")
305+
fi
306+
if [ "${#TAGS_TO_PUSH[@]}" -gt 0 ]; then
307+
run_required_bounded "pushing release tags atomically" 120 git push --atomic origin "${TAGS_TO_PUSH[@]}"
308+
else
309+
echo "Release tags already present on origin"
310+
fi
311+
312+
if [ "$CREATE_RELEASE_CORE" = true ]; then
313+
run_required_bounded \
314+
"creating GitHub release $TAG_CORE" \
315+
120 \
316+
gh release create "$TAG_CORE" --generate-notes --title "$TAG_CORE"
317+
fi
318+
if [ "$CREATE_RELEASE_SDK" = true ]; then
319+
run_required_bounded \
320+
"creating GitHub release $TAG_SDK" \
321+
120 \
322+
gh release create "$TAG_SDK" --generate-notes --title "$TAG_SDK"
323+
fi
265324
echo "Done. Actions publish to npm when each release is in published state."

scripts/release/gh-release.test.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async function createReleaseHelperFixture(
3030
options: {
3131
ghReleaseViewScript: string;
3232
localTagMode?: "absent" | "annotated-at-head" | "lightweight-at-head" | "wrong-target";
33+
remoteTagMode?: "absent" | "annotated-at-head" | "lightweight-at-head" | "wrong-target";
3334
},
3435
): Promise<{ binDir: string; logPath: string; scriptPath: string; tempDir: string }> {
3536
const tempDir = await mkdtemp(path.join(tmpdir(), "ray-gh-release-helper-"));
@@ -44,6 +45,7 @@ async function createReleaseHelperFixture(
4445
const binDir = path.join(tempDir, "bin");
4546
const logPath = path.join(tempDir, "commands.log");
4647
const localTagMode = options.localTagMode ?? "absent";
48+
const remoteTagMode = options.remoteTagMode ?? "absent";
4749

4850
await writeExecutable(
4951
path.join(binDir, "timeout"),
@@ -85,6 +87,7 @@ async function createReleaseHelperFixture(
8587
"#!/usr/bin/env bash",
8688
"set -euo pipefail",
8789
`LOCAL_TAG_MODE=${JSON.stringify(localTagMode)}`,
90+
`REMOTE_TAG_MODE=${JSON.stringify(remoteTagMode)}`,
8891
'printf "git %s\\n" "$*" >> "${RAY_TEST_LOG:?}"',
8992
'if [ "${1:-}" = "status" ]; then',
9093
" exit 0",
@@ -127,7 +130,31 @@ async function createReleaseHelperFixture(
127130
" exit 0",
128131
"fi",
129132
'if [ "${1:-}" = "ls-remote" ]; then',
130-
" exit 2",
133+
' if [ "$REMOTE_TAG_MODE" = "absent" ]; then',
134+
" exit 2",
135+
" fi",
136+
' tag_ref=""',
137+
' for arg in "$@"; do',
138+
' if [[ "$arg" == refs/tags/* && "$arg" != *"^{}" ]]; then',
139+
' tag_ref="$arg"',
140+
" fi",
141+
" done",
142+
' tag_name="${tag_ref#refs/tags/}"',
143+
' if [ -z "$tag_name" ]; then',
144+
' echo "missing tag ref" >&2',
145+
" exit 98",
146+
" fi",
147+
' if [ "$REMOTE_TAG_MODE" = "lightweight-at-head" ]; then',
148+
' printf "abcdef1234567890\\trefs/tags/%s\\n" "$tag_name"',
149+
" exit 0",
150+
" fi",
151+
' printf "tagobject123456789\\trefs/tags/%s\\n" "$tag_name"',
152+
' if [ "$REMOTE_TAG_MODE" = "wrong-target" ]; then',
153+
' printf "deadbeef00000000\\trefs/tags/%s^{}\\n" "$tag_name"',
154+
" else",
155+
' printf "abcdef1234567890\\trefs/tags/%s^{}\\n" "$tag_name"',
156+
" fi",
157+
" exit 0",
131158
"fi",
132159
'if [ "${1:-}" = "push" ] && [ "${2:-}" != "--atomic" ]; then',
133160
' echo "release tag push must be atomic" >&2',
@@ -227,7 +254,7 @@ test("gh release helper gates destructive releases on clean synced main", async
227254
contents,
228255
/run_required_bounded "checking GitHub CLI authentication" 60 gh auth status/,
229256
);
230-
assert.match(contents, /remote_tag_exists "\$tag"/);
257+
assert.match(contents, /remote_release_tag_ready "\$tag" "\$LOCAL_HEAD"/);
231258
assert.match(contents, /github_release_exists "\$tag"/);
232259
assert.match(contents, /bun \.\/scripts\/release\/check-source\.mjs "\$VER"/);
233260
});
@@ -240,7 +267,12 @@ test("gh release helper bounds network release operations", async () => {
240267
assert.match(contents, /timeout "\$\{seconds\}s" "\$@"/);
241268
assert.match(contents, /return 124/);
242269
assert.match(contents, /git fetch --tags origin refs\/heads\/main:refs\/remotes\/origin\/main/);
243-
assert.match(contents, /run_bounded 60 git ls-remote --exit-code --tags origin/);
270+
assert.match(
271+
contents,
272+
/run_bounded 60 git ls-remote --exit-code --tags origin "refs\/tags\/\$tag" "refs\/tags\/\$tag\^\{\}"/,
273+
);
274+
assert.match(contents, /remote_release_tag_ready\(\) \{/);
275+
assert.match(contents, /fail "timed out checking remote tag \$tag"/);
244276
assert.match(contents, /fail "could not check remote tag \$tag \(exit \$status\)"/);
245277
assert.match(contents, /local_release_tag_ready\(\) \{/);
246278
assert.match(contents, /git cat-file -t "refs\/tags\/\$tag"/);
@@ -331,6 +363,68 @@ test("gh release helper reuses matching local annotated tags", async (t) => {
331363
assert.match(commandLog, /^git push --atomic origin core-v1\.2\.3 sdk-v1\.2\.3$/m);
332364
});
333365

366+
test("gh release helper resumes after remote tags or releases already exist safely", async (t) => {
367+
const fixture = await createReleaseHelperFixture(t, {
368+
ghReleaseViewScript: [
369+
' if [ "${3:-}" = "core-v1.2.3" ]; then',
370+
" exit 0",
371+
" fi",
372+
' echo "release not found" >&2',
373+
" exit 1",
374+
].join("\n"),
375+
localTagMode: "annotated-at-head",
376+
remoteTagMode: "annotated-at-head",
377+
});
378+
379+
const result = await runFixtureReleaseHelper(fixture);
380+
381+
assert.equal(result.code, 0);
382+
assert.match(result.stdout, /Reusing remote annotated tag core-v1\.2\.3/);
383+
assert.match(result.stdout, /Reusing remote annotated tag sdk-v1\.2\.3/);
384+
assert.match(result.stdout, /Reusing GitHub release core-v1\.2\.3/);
385+
assert.match(result.stdout, /Release tags already present on origin/);
386+
387+
const commandLog = await readFile(fixture.logPath, "utf8");
388+
assert.doesNotMatch(commandLog, /^git tag -a /m);
389+
assert.doesNotMatch(commandLog, /^git push /m);
390+
assert.doesNotMatch(
391+
commandLog,
392+
/^gh release create core-v1\.2\.3 --generate-notes --title core-v1\.2\.3$/m,
393+
);
394+
assert.match(
395+
commandLog,
396+
/^gh release create sdk-v1\.2\.3 --generate-notes --title sdk-v1\.2\.3$/m,
397+
);
398+
});
399+
400+
test("gh release helper rejects unsafe existing remote tags", async (t) => {
401+
const lightweightFixture = await createReleaseHelperFixture(t, {
402+
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),
403+
remoteTagMode: "lightweight-at-head",
404+
});
405+
406+
const lightweightResult = await runFixtureReleaseHelper(lightweightFixture);
407+
408+
assert.notEqual(lightweightResult.code, 0);
409+
assert.match(
410+
lightweightResult.stderr,
411+
/remote tag core-v1\.2\.3 already exists but is not an annotated tag or could not be peeled/,
412+
);
413+
414+
const wrongTargetFixture = await createReleaseHelperFixture(t, {
415+
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),
416+
remoteTagMode: "wrong-target",
417+
});
418+
419+
const wrongTargetResult = await runFixtureReleaseHelper(wrongTargetFixture);
420+
421+
assert.notEqual(wrongTargetResult.code, 0);
422+
assert.match(
423+
wrongTargetResult.stderr,
424+
/remote tag core-v1\.2\.3 points at deadbeef00000000, expected abcdef1234567890/,
425+
);
426+
});
427+
334428
test("gh release helper rejects unsafe existing local tags", async (t) => {
335429
const lightweightFixture = await createReleaseHelperFixture(t, {
336430
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),

0 commit comments

Comments
 (0)