Skip to content

Commit 4e8cc79

Browse files
authored
Merge pull request #278 from realsigridjin/fix/upstream-release-remote
fix(scripts): decouple release detector from fork remotes
2 parents e7e0ae4 + a0baeb6 commit 4e8cc79

2 files changed

Lines changed: 131 additions & 63 deletions

File tree

scripts/check-upstream-release.mjs

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@
2222
import { execFileSync } from "node:child_process";
2323
import { appendFileSync, readFileSync } from "node:fs";
2424

25-
const UPSTREAM_REMOTE = "upstream";
2625
const UPSTREAM_REPO = "badlogic/pi-mono";
26+
const DEFAULT_UPSTREAM_REMOTE_URL = `https://github.com/${UPSTREAM_REPO}.git`;
2727
const PIN_PATH = ".github/upstream.json";
2828

29+
function upstreamRemoteUrl() {
30+
return process.env.SENPI_UPSTREAM_REMOTE_URL || DEFAULT_UPSTREAM_REMOTE_URL;
31+
}
32+
2933
function log(message) {
3034
process.stderr.write(`[check-upstream] ${message}\n`);
3135
}
@@ -43,7 +47,7 @@ function tryRun(bin, args) {
4347
}
4448

4549
function hasGh() {
46-
return tryRun("gh", ["--version"]).ok;
50+
return !process.env.SENPI_UPSTREAM_REMOTE_URL && tryRun("gh", ["--version"]).ok;
4751
}
4852

4953
function latestUpstreamReleaseTag() {
@@ -55,7 +59,7 @@ function latestUpstreamReleaseTag() {
5559
log("gh releases/latest unavailable; falling back to remote tags");
5660
}
5761

58-
const lsRemote = run("git", ["ls-remote", "--tags", "--refs", UPSTREAM_REMOTE, "v*"]);
62+
const lsRemote = run("git", ["ls-remote", "--tags", "--refs", upstreamRemoteUrl(), "v*"]);
5963
const tags = lsRemote
6064
.split("\n")
6165
.filter(Boolean)
@@ -80,21 +84,42 @@ function compareSemver(a, b) {
8084
}
8185

8286
function resolveTagSha(tag) {
83-
// Fetch the tag so the commit object exists locally for the ancestry check.
84-
const fetch = tryRun("git", ["fetch", "--quiet", UPSTREAM_REMOTE, "--no-tags", `+refs/tags/${tag}:refs/upstream-tags/${tag}`]);
85-
if (!fetch.ok) {
86-
// Fall back to a full tag fetch.
87-
tryRun("git", ["fetch", "--quiet", "--tags", UPSTREAM_REMOTE]);
88-
}
89-
const peeled = tryRun("git", ["rev-parse", `refs/upstream-tags/${tag}^{commit}`]);
90-
if (peeled.ok && peeled.stdout) {
91-
return peeled.stdout;
87+
const remoteUrl = upstreamRemoteUrl();
88+
const refs = run("git", [
89+
"ls-remote",
90+
"--tags",
91+
remoteUrl,
92+
`refs/tags/${tag}`,
93+
`refs/tags/${tag}^{}`,
94+
])
95+
.split("\n")
96+
.filter(Boolean);
97+
const peeled = refs.find((line) => line.endsWith(`refs/tags/${tag}^{}`));
98+
const direct = refs.find((line) => line.endsWith(`refs/tags/${tag}`));
99+
const sha = (peeled ?? direct)?.split(/\s+/, 1)[0];
100+
if (!sha) {
101+
throw new Error(`upstream tag ${tag} could not be resolved`);
92102
}
93-
return run("git", ["rev-parse", `${tag}^{commit}`]);
103+
104+
run("git", [
105+
"fetch",
106+
"--quiet",
107+
"--no-tags",
108+
"--no-write-fetch-head",
109+
remoteUrl,
110+
`refs/tags/${tag}`,
111+
]);
112+
run("git", ["cat-file", "-e", `${sha}^{commit}`]);
113+
return sha;
94114
}
95115

96116
function resolveUpstreamHeadSha() {
97-
return run("git", ["rev-parse", `${UPSTREAM_REMOTE}/main`]);
117+
const output = run("git", ["ls-remote", upstreamRemoteUrl(), "refs/heads/main"]);
118+
const sha = output.split(/\s+/, 1)[0];
119+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
120+
throw new Error("upstream main could not be resolved");
121+
}
122+
return sha;
98123
}
99124

100125
function currentPinTag() {
@@ -129,7 +154,7 @@ function main() {
129154
const sha = resolveTagSha(tag);
130155
log(`resolved ${tag} -> ${sha}`);
131156
const upstreamHeadSha = resolveUpstreamHeadSha();
132-
log(`resolved ${UPSTREAM_REMOTE}/main -> ${upstreamHeadSha}`);
157+
log(`resolved ${UPSTREAM_REPO} main -> ${upstreamHeadSha}`);
133158

134159
if (force) {
135160
log("--force set; proceeding regardless of merge state");
Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,109 @@
11
import assert from "node:assert/strict";
2-
import { execFileSync } from "node:child_process";
3-
import { after, before, describe, it } from "node:test";
2+
import { execFileSync, spawnSync } from "node:child_process";
3+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import path from "node:path";
6+
import { afterEach, beforeEach, describe, it } from "node:test";
7+
import { fileURLToPath } from "node:url";
48

5-
const UPSTREAM_REMOTE_URL = "https://github.com/badlogic/pi-mono.git";
9+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10+
const SCRIPT_PATH = path.join(REPO_ROOT, "scripts/check-upstream-release.mjs");
611

7-
let addedUpstreamRemote = false;
12+
function runGit(cwd, args) {
13+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
14+
}
815

9-
function git(args) {
10-
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
16+
function commitFile(cwd, name, content) {
17+
writeFileSync(path.join(cwd, name), content);
18+
runGit(cwd, ["add", name]);
19+
runGit(cwd, ["commit", "-m", `add ${name}`]);
20+
return runGit(cwd, ["rev-parse", "HEAD"]);
1121
}
1222

13-
function tryGit(args) {
14-
try {
15-
return git(args);
16-
} catch {
17-
return "";
18-
}
23+
function parseOutput(stdout) {
24+
return Object.fromEntries(
25+
stdout
26+
.trim()
27+
.split("\n")
28+
.map((line) => line.split("=", 2)),
29+
);
1930
}
2031

2132
describe("upstream release detector outputs", () => {
22-
let upstreamAvailable = false;
33+
let root;
34+
let upstreamWork;
35+
let upstreamBare;
36+
let checkout;
37+
38+
beforeEach(() => {
39+
root = mkdtempSync(path.join(tmpdir(), "senpi-upstream-release-"));
40+
upstreamWork = path.join(root, "upstream-work");
41+
upstreamBare = path.join(root, "upstream.git");
42+
checkout = path.join(root, "checkout");
43+
44+
mkdirSync(upstreamWork);
45+
runGit(upstreamWork, ["init", "-b", "main"]);
46+
runGit(upstreamWork, ["config", "user.name", "Test"]);
47+
runGit(upstreamWork, ["config", "user.email", "test@example.com"]);
48+
const firstSha = commitFile(upstreamWork, "first.txt", "first");
49+
runGit(upstreamWork, ["tag", "v1.2.3", firstSha]);
50+
const releaseSha = commitFile(upstreamWork, "release.txt", "release");
51+
runGit(upstreamWork, ["tag", "-a", "v1.10.0", "-m", "release", releaseSha]);
52+
const mainSha = commitFile(upstreamWork, "main.txt", "main");
53+
runGit(root, ["clone", "--bare", upstreamWork, upstreamBare]);
54+
55+
mkdirSync(checkout);
56+
runGit(checkout, ["init", "-b", "main"]);
57+
runGit(checkout, ["config", "user.name", "Test"]);
58+
runGit(checkout, ["config", "user.email", "test@example.com"]);
59+
commitFile(checkout, "local.txt", "local");
60+
runGit(checkout, ["remote", "add", "upstream", path.join(root, "wrong.git")]);
61+
runGit(checkout, ["tag", "v1.10.0"]);
62+
mkdirSync(path.join(checkout, ".github"));
63+
writeFileSync(path.join(checkout, ".github/upstream.json"), '{"tag":"v1.2.3"}\n');
2364

24-
before(() => {
25-
if (!tryGit(["remote", "get-url", "upstream"])) {
26-
if (!tryGit(["remote", "add", "upstream", UPSTREAM_REMOTE_URL])) return;
27-
addedUpstreamRemote = true;
28-
}
29-
// A real fetch of the upstream GitHub repo. On credential-less/offline
30-
// runners (e.g. the release publish job checks out with
31-
// persist-credentials:false) this cannot authenticate — skip rather than
32-
// hard-fail the whole `test:scripts` suite, since this test inherently
33-
// requires the external upstream repo.
34-
if (tryGit(["fetch", "--quiet", "upstream", "+refs/heads/main:refs/remotes/upstream/main"]) === "" && !tryGit(["rev-parse", "upstream/main"])) {
35-
return;
36-
}
37-
upstreamAvailable = tryGit(["rev-parse", "upstream/main"]) !== "";
65+
assert.equal(runGit(upstreamWork, ["rev-parse", "HEAD"]), mainSha);
66+
assert.equal(runGit(upstreamWork, ["rev-list", "-n", "1", "v1.10.0"]), releaseSha);
3867
});
3968

40-
after(() => {
41-
if (addedUpstreamRemote) {
42-
git(["remote", "remove", "upstream"]);
43-
}
69+
afterEach(() => {
70+
rmSync(root, { recursive: true, force: true });
4471
});
4572

46-
it("preserves the release tag sha and emits upstream/main head separately on forced runs", (t) => {
47-
if (!upstreamAvailable) {
48-
t.skip("upstream remote unreachable (offline or no git credentials)");
49-
return;
50-
}
51-
const stdout = execFileSync("node", ["scripts/check-upstream-release.mjs", "--force"], { encoding: "utf8" });
52-
const output = Object.fromEntries(
53-
stdout
54-
.trim()
55-
.split("\n")
56-
.map((line) => line.split("=", 2)),
57-
);
58-
const upstreamMain = git(["rev-parse", "upstream/main"]);
59-
const releaseTag = output.tag;
60-
const releaseSha = tryGit(["rev-parse", `refs/upstream-tags/${releaseTag}^{commit}`]) || git(["rev-parse", `${releaseTag}^{commit}`]);
73+
it("uses authoritative remote tag and main SHAs without changing remotes or refs", () => {
74+
const remoteBefore = runGit(checkout, ["remote", "get-url", "upstream"]);
75+
const stdout = execFileSync("node", [SCRIPT_PATH, "--force"], {
76+
cwd: checkout,
77+
encoding: "utf8",
78+
env: { ...process.env, GITHUB_OUTPUT: "", SENPI_UPSTREAM_REMOTE_URL: upstreamBare },
79+
});
80+
const output = parseOutput(stdout);
6181

6282
assert.equal(output.proceed, "true");
63-
assert.equal(output.sha, releaseSha);
64-
assert.equal(output.upstream_head_sha, upstreamMain);
83+
assert.equal(output.tag, "v1.10.0");
84+
assert.equal(output.sha, runGit(upstreamWork, ["rev-list", "-n", "1", "v1.10.0"]));
85+
assert.equal(output.upstream_head_sha, runGit(upstreamWork, ["rev-parse", "main"]));
86+
assert.equal(output.current_tag, "v1.2.3");
87+
assert.equal(runGit(checkout, ["remote", "get-url", "upstream"]), remoteBefore);
88+
assert.equal(runGit(checkout, ["for-each-ref", "--format=%(refname)", "refs/upstream-tags", "refs/remotes/pi-mono"]), "");
89+
assert.equal(runGit(checkout, ["rev-parse", "v1.10.0"]), runGit(checkout, ["rev-parse", "HEAD"]));
90+
});
91+
92+
it("fails closed without trusting a colliding local tag when remote fetch fails", () => {
93+
const outputPath = path.join(root, "github-output.txt");
94+
const result = spawnSync("node", [SCRIPT_PATH, "--force"], {
95+
cwd: checkout,
96+
encoding: "utf8",
97+
env: {
98+
...process.env,
99+
GITHUB_OUTPUT: outputPath,
100+
SENPI_UPSTREAM_REMOTE_URL: path.join(root, "missing.git"),
101+
},
102+
});
103+
104+
assert.equal(result.status, 1);
105+
assert.match(result.stdout, /^proceed=false$/m);
106+
assert.equal(readFileSync(outputPath, "utf8"), "proceed=false\n");
107+
assert.equal(runGit(checkout, ["rev-parse", "v1.10.0"]), runGit(checkout, ["rev-parse", "HEAD"]));
65108
});
66109
});

0 commit comments

Comments
 (0)