| 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 |