Skip to content

Commit 165ef8e

Browse files
committed
Harden GitHub release probes
1 parent e452a30 commit 165ef8e

2 files changed

Lines changed: 274 additions & 23 deletions

File tree

scripts/release/gh-release.sh

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,33 @@ run_bounded() {
5858
return "$status"
5959
}
6060

61+
run_required_bounded() {
62+
local description="$1"
63+
local seconds="$2"
64+
shift 2
65+
66+
local status=0
67+
run_bounded "$seconds" "$@" || status=$?
68+
if [ "$status" -eq 0 ]; then
69+
return 0
70+
fi
71+
if [ "$status" -eq 124 ]; then
72+
fail "timed out after ${seconds}s while ${description}"
73+
fi
74+
75+
fail "${description} failed (exit $status)"
76+
}
77+
6178
remote_tag_exists() {
6279
local tag="$1"
63-
if run_bounded 60 git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
80+
local status=0
81+
run_bounded 60 git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 ||
82+
status=$?
83+
84+
if [ "$status" -eq 0 ]; then
6485
return 0
6586
fi
6687

67-
local status=$?
6888
if [ "$status" -eq 2 ]; then
6989
return 1
7090
fi
@@ -74,16 +94,33 @@ remote_tag_exists() {
7494

7595
github_release_exists() {
7696
local tag="$1"
77-
if run_bounded 60 gh release view "$tag" >/dev/null 2>&1; then
97+
local stderr_file
98+
stderr_file="$(mktemp "${TMPDIR:-/tmp}/ray-gh-release-view.XXXXXX")"
99+
100+
local status=0
101+
run_bounded 60 gh release view "$tag" >/dev/null 2>"$stderr_file" || status=$?
102+
if [ "$status" -eq 0 ]; then
103+
rm -f "$stderr_file"
78104
return 0
79105
fi
80106

81-
local status=$?
107+
local message
108+
message="$(head -n 1 "$stderr_file" | tr -d '\r' || true)"
109+
rm -f "$stderr_file"
110+
82111
if [ "$status" -eq 124 ]; then
83112
fail "timed out checking GitHub release: $tag"
84113
fi
85114

86-
return 1
115+
if printf '%s\n' "$message" | grep -Eiq '(^|[[:space:]])release[[:space:]-]+not[[:space:]-]+found|no release found'; then
116+
return 1
117+
fi
118+
119+
if [ -n "$message" ]; then
120+
fail "could not check GitHub release $tag (exit $status): $message"
121+
fi
122+
123+
fail "could not check GitHub release $tag (exit $status)"
87124
}
88125

89126
usage() {
@@ -154,13 +191,18 @@ if [ "$BRANCH" != "main" ]; then
154191
fail "release helper must run from main; current branch is ${BRANCH:-detached}"
155192
fi
156193

157-
run_bounded 120 git fetch --tags origin refs/heads/main:refs/remotes/origin/main
194+
run_required_bounded \
195+
"fetching origin/main and release tags" \
196+
120 \
197+
git fetch --tags origin refs/heads/main:refs/remotes/origin/main
158198
LOCAL_HEAD="$(git rev-parse HEAD)"
159199
REMOTE_HEAD="$(git rev-parse origin/main)"
160200
if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
161201
fail "local HEAD $LOCAL_HEAD does not match origin/main $REMOTE_HEAD; push or pull main before releasing"
162202
fi
163203

204+
run_required_bounded "checking GitHub CLI authentication" 60 gh auth status >/dev/null
205+
164206
for tag in "$TAG_CORE" "$TAG_SDK"; do
165207
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
166208
fail "local tag already exists: $tag"
@@ -173,13 +215,15 @@ for tag in "$TAG_CORE" "$TAG_SDK"; do
173215
fi
174216
done
175217

176-
if ! run_bounded 60 gh auth status >/dev/null; then
177-
fail "gh is not authenticated"
178-
fi
179-
180218
git tag -a "$TAG_CORE" -m "Release $TAG_CORE (@razroo/ray-core v$VER)"
181219
git tag -a "$TAG_SDK" -m "Release $TAG_SDK (@razroo/ray-sdk v$VER)"
182-
run_bounded 120 git push origin "$TAG_CORE" "$TAG_SDK"
183-
run_bounded 120 gh release create "$TAG_CORE" --generate-notes --title "$TAG_CORE"
184-
run_bounded 120 gh release create "$TAG_SDK" --generate-notes --title "$TAG_SDK"
220+
run_required_bounded "pushing release tags" 120 git push origin "$TAG_CORE" "$TAG_SDK"
221+
run_required_bounded \
222+
"creating GitHub release $TAG_CORE" \
223+
120 \
224+
gh release create "$TAG_CORE" --generate-notes --title "$TAG_CORE"
225+
run_required_bounded \
226+
"creating GitHub release $TAG_SDK" \
227+
120 \
228+
gh release create "$TAG_SDK" --generate-notes --title "$TAG_SDK"
185229
echo "Done. Actions publish to npm when each release is in published state."

scripts/release/gh-release.test.ts

Lines changed: 217 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import assert from "node:assert/strict";
22
import { execFile } from "node:child_process";
3-
import { readFile } from "node:fs/promises";
3+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
45
import path from "node:path";
5-
import test from "node:test";
6+
import test, { type TestContext } from "node:test";
67
import { promisify } from "node:util";
78

89
const execFileAsync = promisify(execFile);
@@ -18,6 +19,154 @@ async function readCurrentVersion(): Promise<string> {
1819
return (JSON.parse(raw) as { version: string }).version;
1920
}
2021

22+
async function writeExecutable(filePath: string, contents: string): Promise<void> {
23+
await mkdir(path.dirname(filePath), { recursive: true });
24+
await writeFile(filePath, contents, "utf8");
25+
await chmod(filePath, 0o755);
26+
}
27+
28+
async function createReleaseHelperFixture(
29+
t: TestContext,
30+
options: { ghReleaseViewScript: string },
31+
): Promise<{ binDir: string; logPath: string; scriptPath: string; tempDir: string }> {
32+
const tempDir = await mkdtemp(path.join(tmpdir(), "ray-gh-release-helper-"));
33+
t.after(async () => {
34+
await rm(tempDir, { recursive: true, force: true });
35+
});
36+
37+
const fixtureScriptPath = path.join(tempDir, "scripts", "release", "gh-release.sh");
38+
await mkdir(path.dirname(fixtureScriptPath), { recursive: true });
39+
await writeFile(fixtureScriptPath, await readFile(scriptPath, "utf8"), "utf8");
40+
41+
const binDir = path.join(tempDir, "bin");
42+
const logPath = path.join(tempDir, "commands.log");
43+
44+
await writeExecutable(
45+
path.join(binDir, "timeout"),
46+
[
47+
"#!/usr/bin/env bash",
48+
"set -euo pipefail",
49+
'if [ "$#" -lt 2 ]; then',
50+
' echo "timeout stub requires a duration and command" >&2',
51+
" exit 125",
52+
"fi",
53+
"shift",
54+
'"$@"',
55+
"",
56+
].join("\n"),
57+
);
58+
59+
await writeExecutable(
60+
path.join(binDir, "bun"),
61+
[
62+
"#!/usr/bin/env bash",
63+
"set -euo pipefail",
64+
'printf "bun %s\\n" "$*" >> "${RAY_TEST_LOG:?}"',
65+
'if [ "${1:-}" = "--print" ]; then',
66+
' echo "1.2.3"',
67+
" exit 0",
68+
"fi",
69+
'if [ "${1:-}" = "./scripts/release/check-source.mjs" ] && [ "${2:-}" = "1.2.3" ]; then',
70+
" exit 0",
71+
"fi",
72+
'echo "unexpected bun invocation: $*" >&2',
73+
"exit 98",
74+
"",
75+
].join("\n"),
76+
);
77+
78+
await writeExecutable(
79+
path.join(binDir, "git"),
80+
[
81+
"#!/usr/bin/env bash",
82+
"set -euo pipefail",
83+
'printf "git %s\\n" "$*" >> "${RAY_TEST_LOG:?}"',
84+
'if [ "${1:-}" = "status" ]; then',
85+
" exit 0",
86+
"fi",
87+
'if [ "${1:-}" = "branch" ] && [ "${2:-}" = "--show-current" ]; then',
88+
' echo "main"',
89+
" exit 0",
90+
"fi",
91+
'if [ "${1:-}" = "fetch" ]; then',
92+
" exit 0",
93+
"fi",
94+
'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "HEAD" ]; then',
95+
' echo "abcdef1234567890"',
96+
" exit 0",
97+
"fi",
98+
'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "origin/main" ]; then',
99+
' echo "abcdef1234567890"',
100+
" exit 0",
101+
"fi",
102+
'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "-q" ]; then',
103+
" exit 1",
104+
"fi",
105+
'if [ "${1:-}" = "ls-remote" ]; then',
106+
" exit 2",
107+
"fi",
108+
'if [ "${1:-}" = "tag" ] || [ "${1:-}" = "push" ]; then',
109+
" exit 0",
110+
"fi",
111+
'echo "unexpected git invocation: $*" >&2',
112+
"exit 98",
113+
"",
114+
].join("\n"),
115+
);
116+
117+
await writeExecutable(
118+
path.join(binDir, "gh"),
119+
[
120+
"#!/usr/bin/env bash",
121+
"set -euo pipefail",
122+
'printf "gh %s\\n" "$*" >> "${RAY_TEST_LOG:?}"',
123+
'if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then',
124+
" exit 0",
125+
"fi",
126+
'if [ "${1:-}" = "release" ] && [ "${2:-}" = "view" ]; then',
127+
options.ghReleaseViewScript,
128+
"fi",
129+
'if [ "${1:-}" = "release" ] && [ "${2:-}" = "create" ]; then',
130+
" exit 0",
131+
"fi",
132+
'echo "unexpected gh invocation: $*" >&2',
133+
"exit 98",
134+
"",
135+
].join("\n"),
136+
);
137+
138+
return { binDir, logPath, scriptPath: fixtureScriptPath, tempDir };
139+
}
140+
141+
async function runFixtureReleaseHelper(fixture: {
142+
binDir: string;
143+
logPath: string;
144+
scriptPath: string;
145+
tempDir: string;
146+
}): Promise<{ code: number; stderr: string; stdout: string }> {
147+
try {
148+
const { stderr, stdout } = await execFileAsync("bash", [fixture.scriptPath, "--yes"], {
149+
cwd: fixture.tempDir,
150+
env: {
151+
...process.env,
152+
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH ?? ""}`,
153+
RAY_TEST_LOG: fixture.logPath,
154+
},
155+
maxBuffer: 64 * 1024,
156+
timeout: 5_000,
157+
});
158+
159+
return { code: 0, stderr, stdout };
160+
} catch (error) {
161+
const result = error as { code?: number | string; stderr?: string; stdout?: string };
162+
return {
163+
code: typeof result.code === "number" ? result.code : 1,
164+
stderr: result.stderr ?? "",
165+
stdout: result.stdout ?? "",
166+
};
167+
}
168+
}
169+
21170
test("gh release helper validates syntax and dry-runs without mutating git state", async () => {
22171
const version = await readCurrentVersion();
23172
const escapedVersion = escapeRegExp(version);
@@ -46,6 +195,10 @@ test("gh release helper gates destructive releases on clean synced main", async
46195
assert.match(contents, /refs\/heads\/main:refs\/remotes\/origin\/main/);
47196
assert.match(contents, /git rev-parse HEAD/);
48197
assert.match(contents, /git rev-parse origin\/main/);
198+
assert.match(
199+
contents,
200+
/run_required_bounded "checking GitHub CLI authentication" 60 gh auth status/,
201+
);
49202
assert.match(contents, /remote_tag_exists "\$tag"/);
50203
assert.match(contents, /github_release_exists "\$tag"/);
51204
assert.match(contents, /bun \.\/scripts\/release\/check-source\.mjs "\$VER"/);
@@ -55,18 +208,72 @@ test("gh release helper bounds network release operations", async () => {
55208
const contents = await readFile(scriptPath, "utf8");
56209

57210
assert.match(contents, /run_bounded\(\) \{/);
211+
assert.match(contents, /run_required_bounded\(\) \{/);
58212
assert.match(contents, /timeout "\$\{seconds\}s" "\$@"/);
59213
assert.match(contents, /return 124/);
60-
assert.match(
61-
contents,
62-
/run_bounded 120 git fetch --tags origin refs\/heads\/main:refs\/remotes\/origin\/main/,
63-
);
214+
assert.match(contents, /git fetch --tags origin refs\/heads\/main:refs\/remotes\/origin\/main/);
64215
assert.match(contents, /run_bounded 60 git ls-remote --exit-code --tags origin/);
65216
assert.match(contents, /fail "could not check remote tag \$tag \(exit \$status\)"/);
66217
assert.match(contents, /run_bounded 60 gh release view "\$tag"/);
67218
assert.match(contents, /fail "timed out checking GitHub release: \$tag"/);
68-
assert.match(contents, /run_bounded 60 gh auth status/);
69-
assert.match(contents, /run_bounded 120 git push origin "\$TAG_CORE" "\$TAG_SDK"/);
70-
assert.match(contents, /run_bounded 120 gh release create "\$TAG_CORE"/);
71-
assert.match(contents, /run_bounded 120 gh release create "\$TAG_SDK"/);
219+
assert.match(
220+
contents,
221+
/fail "could not check GitHub release \$tag \(exit \$status\): \$message"/,
222+
);
223+
assert.match(
224+
contents,
225+
/run_required_bounded "checking GitHub CLI authentication" 60 gh auth status/,
226+
);
227+
assert.match(contents, /run_required_bounded "pushing release tags" 120 git push origin/);
228+
assert.match(contents, /"creating GitHub release \$TAG_CORE"/);
229+
assert.match(contents, /"creating GitHub release \$TAG_SDK"/);
230+
});
231+
232+
test("gh release helper aborts ambiguous GitHub release probe failures before tagging", async (t) => {
233+
const fixture = await createReleaseHelperFixture(t, {
234+
ghReleaseViewScript: [' echo "api rate limit exceeded" >&2', " exit 1"].join("\n"),
235+
});
236+
237+
const result = await runFixtureReleaseHelper(fixture);
238+
239+
assert.notEqual(result.code, 0);
240+
assert.match(
241+
result.stderr,
242+
/could not check GitHub release core-v1\.2\.3 \(exit 1\): api rate limit exceeded/,
243+
);
244+
245+
const commandLog = await readFile(fixture.logPath, "utf8");
246+
assert.match(commandLog, /^gh auth status$/m);
247+
assert.match(commandLog, /^gh release view core-v1\.2\.3$/m);
248+
assert.doesNotMatch(commandLog, /^git tag /m);
249+
assert.doesNotMatch(commandLog, /^git push /m);
250+
assert.doesNotMatch(commandLog, /^gh release create /m);
251+
});
252+
253+
test("gh release helper continues when GitHub reports releases are missing", async (t) => {
254+
const fixture = await createReleaseHelperFixture(t, {
255+
ghReleaseViewScript: [' echo "release not found" >&2', " exit 1"].join("\n"),
256+
});
257+
258+
const result = await runFixtureReleaseHelper(fixture);
259+
260+
assert.equal(result.code, 0);
261+
assert.match(
262+
result.stdout,
263+
/Done\. Actions publish to npm when each release is in published state\./,
264+
);
265+
266+
const commandLog = await readFile(fixture.logPath, "utf8");
267+
assert.match(commandLog, /^gh auth status$/m);
268+
assert.match(commandLog, /^git tag -a core-v1\.2\.3 /m);
269+
assert.match(commandLog, /^git tag -a sdk-v1\.2\.3 /m);
270+
assert.match(commandLog, /^git push origin core-v1\.2\.3 sdk-v1\.2\.3$/m);
271+
assert.match(
272+
commandLog,
273+
/^gh release create core-v1\.2\.3 --generate-notes --title core-v1\.2\.3$/m,
274+
);
275+
assert.match(
276+
commandLog,
277+
/^gh release create sdk-v1\.2\.3 --generate-notes --title sdk-v1\.2\.3$/m,
278+
);
72279
});

0 commit comments

Comments
 (0)