返回 CodeWhale
sync-latest-release.mjs
根目录 / web / scripts / sync-latest-release.mjs
1 #!/usr/bin/env node
2 // Refresh the checked-in "latest published release" fact from the real GitHub
3 // release. The fact is mirrored in three places and ALL must move together:
4 //
5 // web/data/latest-published-release.json (read by derive-facts.mjs)
6 // docs/public-surface-facts.json (latestPublishedRelease, which
7 // names the file above as its
8 // `sources`)
9 // docs/cloud-facts/stable.json (release.latest / release_url,
10 // compared by check-cloud-facts)
11 //
12 // web/lib/public-surface-contract.test.ts asserts the first two agree and
13 // check-cloud-facts.mjs asserts the third, so updating only one turns a stale
14 // marketing fact into a red Lint & Type Check. web/lib/facts.generated.ts is
15 // derived from the first file; regenerate it with derive-facts.mjs afterwards.
16 //
17 // release.yml's `sync-release-record` job runs this after every publish and
18 // proposes the result to main as a bot PR, so nobody hand-commits the record.
19 //
20 // Facts must be derivable from the repo with no network (derive-facts.mjs reads
21 // this file, it does not call GitHub), so the file is checked in. Nothing wrote
22 // it, which is why it drifted: the marketing deploy's post-deploy comparison
23 // failed on latestPublishedRelease.tag because this said v0.9.10 while the
24 // published release was v0.9.11.
25 //
26 // node web/scripts/sync-latest-release.mjs # write if changed
27 // node web/scripts/sync-latest-release.mjs --check # exit 1 if stale
28 //
29 // --check is the CI form: it makes drift a failing gate at PR time instead of a
30 // surprise after a production deploy. It only warns while the record is exactly
31 // one release behind a release published under 24h ago: that is the window in
32 // which release.yml's sync-release-record PR is waiting to merge, and neither a
33 // PR author nor an unrelated push to main can fix it. Past 24h, or more than one
34 // release behind, it fails again. An unreachable or failing GitHub API fails
35 // --check too: "could not look" is not "current".
36 //
37 // A write validates all three files first and then replaces each one through a
38 // unique temporary file and a rename, so a bad mirror never leaves the first
39 // file moved on its own and a crash never leaves a half-written JSON file.
40
41 import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
42 import { fileURLToPath } from "node:url";
43 import { dirname, resolve } from "node:path";
44
45 const REPO = "codewhale-hq/CodeWhale";
46 const here = dirname(fileURLToPath(import.meta.url));
47 const target = resolve(here, "..", "data", "latest-published-release.json");
48 const mirror = resolve(here, "..", "..", "docs", "public-surface-facts.json");
49 const cloudFacts = resolve(here, "..", "..", "docs", "cloud-facts", "stable.json");
50 const GRACE_MS = 24 * 60 * 60 * 1000;
51 const checkOnly = process.argv.includes("--check");
52
53 const headers = {
54 accept: "application/vnd.github+json",
55 "user-agent": "codewhale-facts-sync",
56 };
57 if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
58
59 let release;
60 try {
61 const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
62 headers,
63 signal: AbortSignal.timeout(30_000),
64 });
65 if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
66 release = await response.json();
67 } catch (error) {
68 console.error(`[sync-latest-release] could not read the latest release (${error.message}); leaving the files alone.`);
69 process.exit(1);
70 }
71
72 const tag = String(release.tag_name || "");
73 const version = tag.startsWith("v") ? tag.slice(1) : "";
74 const next = {
75 tag,
76 version,
77 publishedAt: String(release.published_at || ""),
78 url: `https://github.com/${REPO}/releases/tag/${tag}`,
79 };
80
81 // deriveLatestPublishedRelease() silently returns null on any shape violation,
82 // which would drop the fact entirely rather than report a bad one. Fail loudly.
83 if (!tag || !version || tag !== `v${version}` || !Number.isFinite(Date.parse(next.publishedAt))) {
84 console.error(`[sync-latest-release] refusing to write an unusable release fact: ${JSON.stringify(next)}`);
85 process.exit(1);
86 }
87
88 const readJson = (path) => {
89 try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
90 };
91
92 const current = readJson(target);
93 const matrix = readJson(mirror);
94 const currentMirror = matrix?.latestPublishedRelease ?? null;
95 const cloud = readJson(cloudFacts);
96
97 const isCurrent = (fact) =>
98 Boolean(fact) && fact.tag === next.tag && fact.publishedAt === next.publishedAt;
99 const cloudIsCurrent = (facts) =>
100 Boolean(facts?.release) && facts.release.latest === next.version && facts.release.release_url === next.url;
101
102 // True when `recordedTag` is the published (non-draft, non-prerelease) release
103 // immediately before `next`, and `next` is younger than GRACE_MS. Any lookup
104 // failure answers false, so the check stays strict when in doubt.
105 async function isFreshlyOneBehind(recordedTag) {
106 const age = Date.now() - Date.parse(next.publishedAt);
107 if (!recordedTag || !(age >= 0 && age < GRACE_MS)) return false;
108 try {
109 const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=20`, {
110 headers,
111 signal: AbortSignal.timeout(30_000),
112 });
113 if (!res.ok) return false;
114 const tags = (await res.json())
115 .filter((r) => !r.draft && !r.prerelease && Number.isFinite(Date.parse(r.published_at)))
116 .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))
117 .map((r) => String(r.tag_name));
118 return tags[0] === next.tag && tags[1] === recordedTag;
119 } catch {
120 return false;
121 }
122 }
123
124 if (isCurrent(current) && isCurrent(currentMirror) && (checkOnly || cloudIsCurrent(cloud))) {
125 console.log(`[sync-latest-release] already current at ${next.tag}`);
126 process.exit(0);
127 }
128
129 if (checkOnly) {
130 if (!isCurrent(current)) {
131 console.error(
132 `[sync-latest-release] stale: ${target} says ${current?.tag ?? "(missing)"}, GitHub says ${next.tag}`,
133 );
134 }
135 if (!isCurrent(currentMirror)) {
136 console.error(
137 `[sync-latest-release] stale: docs/public-surface-facts.json says ${currentMirror?.tag ?? "(missing)"}, GitHub says ${next.tag}`,
138 );
139 }
140 const recorded = current?.tag;
141 if ((current?.tag ?? null) === (currentMirror?.tag ?? null) && (await isFreshlyOneBehind(recorded))) {
142 console.warn(
143 `[sync-latest-release] warning only: ${next.tag} was published under 24h ago and release.yml's ` +
144 "sync-release-record job proposes the record as a PR. Merge that; this change does not need to.",
145 );
146 if (process.env.GITHUB_ACTIONS) {
147 console.log(`::warning title=Release record catching up::${recorded} -> ${next.tag} is pending from release.yml`);
148 }
149 process.exit(0);
150 }
151 console.error("Run: npm --prefix web run sync:latest-release && node web/scripts/derive-facts.mjs");
152 process.exit(1);
153 }
154
155 // Every input must be usable before anything moves: the three files change
156 // together or not at all.
157 if (!matrix) {
158 console.error(`[sync-latest-release] could not read ${mirror}; nothing was written.`);
159 process.exit(1);
160 }
161 // stable.json is the unsigned cloud-facts authoring source; only the two
162 // release pointers move here. yanked/min_supported/notice stay human calls.
163 if (!cloud?.release || typeof cloud.release !== "object") {
164 console.error(`[sync-latest-release] could not read release in ${cloudFacts}; nothing was written.`);
165 process.exit(1);
166 }
167
168 // Preserve every key the matrix carries beyond the four synced fields (notably
169 // `sources`), so this stays a fact refresh and not a schema rewrite.
170 matrix.latestPublishedRelease = { ...currentMirror, ...next };
171 cloud.release.latest = next.version;
172 cloud.release.release_url = next.url;
173
174 const staged = [
175 [target, next],
176 [mirror, matrix],
177 [cloudFacts, cloud],
178 ].map(([path, value]) => {
179 const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
180 writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
181 return [temporary, path];
182 });
183 try {
184 for (const [temporary, path] of staged) renameSync(temporary, path);
185 } finally {
186 for (const [temporary] of staged) rmSync(temporary, { force: true });
187 }
188
189 console.log(`[sync-latest-release] wrote ${next.tag} (${next.publishedAt}) to all three facts`);
190
190 lines Plain Text