|
1 | 1 | 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"; |
4 | 8 |
|
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"); |
6 | 11 |
|
7 | | -let addedUpstreamRemote = false; |
| 12 | +function runGit(cwd, args) { |
| 13 | + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); |
| 14 | +} |
8 | 15 |
|
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"]); |
11 | 21 | } |
12 | 22 |
|
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 | + ); |
19 | 30 | } |
20 | 31 |
|
21 | 32 | 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'); |
23 | 64 |
|
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); |
38 | 67 | }); |
39 | 68 |
|
40 | | - after(() => { |
41 | | - if (addedUpstreamRemote) { |
42 | | - git(["remote", "remove", "upstream"]); |
43 | | - } |
| 69 | + afterEach(() => { |
| 70 | + rmSync(root, { recursive: true, force: true }); |
44 | 71 | }); |
45 | 72 |
|
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); |
61 | 81 |
|
62 | 82 | 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"])); |
65 | 108 | }); |
66 | 109 | }); |
0 commit comments