| 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 two places and BOTH 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 | // |
| 10 | // web/lib/public-surface-contract.test.ts asserts the two agree, so updating |
| 11 | // only the first turns a stale marketing fact into a red Lint & Type Check. |
| 12 | // |
| 13 | // Facts must be derivable from the repo with no network (derive-facts.mjs reads |
| 14 | // this file, it does not call GitHub), so the file is checked in. Nothing wrote |
| 15 | // it, which is why it drifted: the marketing deploy's post-deploy comparison |
| 16 | // failed on latestPublishedRelease.tag because this said v0.9.10 while the |
| 17 | // published release was v0.9.11. |
| 18 | // |
| 19 | // node web/scripts/sync-latest-release.mjs # write if changed |
| 20 | // node web/scripts/sync-latest-release.mjs --check # exit 1 if stale |
| 21 | // |
| 22 | // --check is the CI form: it makes drift a failing gate at PR time instead of a |
| 23 | // surprise after a production deploy. |
| 24 | |
| 25 | import { readFileSync, writeFileSync } from "node:fs"; |
| 26 | import { fileURLToPath } from "node:url"; |
| 27 | import { dirname, resolve } from "node:path"; |
| 28 | |
| 29 | const REPO = "Hmbown/CodeWhale"; |
| 30 | const here = dirname(fileURLToPath(import.meta.url)); |
| 31 | const target = resolve(here, "..", "data", "latest-published-release.json"); |
| 32 | const mirror = resolve(here, "..", "..", "docs", "public-surface-facts.json"); |
| 33 | const checkOnly = process.argv.includes("--check"); |
| 34 | |
| 35 | const headers = { |
| 36 | accept: "application/vnd.github+json", |
| 37 | "user-agent": "codewhale-facts-sync", |
| 38 | }; |
| 39 | if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; |
| 40 | |
| 41 | const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers }); |
| 42 | if (!response.ok) { |
| 43 | console.error(`[sync-latest-release] GitHub returned ${response.status}; leaving the file alone.`); |
| 44 | process.exit(checkOnly ? 0 : 1); |
| 45 | } |
| 46 | const release = await response.json(); |
| 47 | |
| 48 | const tag = String(release.tag_name || ""); |
| 49 | const version = tag.startsWith("v") ? tag.slice(1) : ""; |
| 50 | const next = { |
| 51 | tag, |
| 52 | version, |
| 53 | publishedAt: String(release.published_at || ""), |
| 54 | url: `https://github.com/${REPO}/releases/tag/${tag}`, |
| 55 | }; |
| 56 | |
| 57 | // deriveLatestPublishedRelease() silently returns null on any shape violation, |
| 58 | // which would drop the fact entirely rather than report a bad one. Fail loudly. |
| 59 | if (!tag || !version || tag !== `v${version}` || !Number.isFinite(Date.parse(next.publishedAt))) { |
| 60 | console.error(`[sync-latest-release] refusing to write an unusable release fact: ${JSON.stringify(next)}`); |
| 61 | process.exit(1); |
| 62 | } |
| 63 | |
| 64 | const readJson = (path) => { |
| 65 | try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } |
| 66 | }; |
| 67 | |
| 68 | const current = readJson(target); |
| 69 | const matrix = readJson(mirror); |
| 70 | const currentMirror = matrix?.latestPublishedRelease ?? null; |
| 71 | |
| 72 | const isCurrent = (fact) => |
| 73 | Boolean(fact) && fact.tag === next.tag && fact.publishedAt === next.publishedAt; |
| 74 | |
| 75 | if (isCurrent(current) && isCurrent(currentMirror)) { |
| 76 | console.log(`[sync-latest-release] already current at ${next.tag}`); |
| 77 | process.exit(0); |
| 78 | } |
| 79 | |
| 80 | if (checkOnly) { |
| 81 | if (!isCurrent(current)) { |
| 82 | console.error( |
| 83 | `[sync-latest-release] stale: ${target} says ${current?.tag ?? "(missing)"}, GitHub says ${next.tag}`, |
| 84 | ); |
| 85 | } |
| 86 | if (!isCurrent(currentMirror)) { |
| 87 | console.error( |
| 88 | `[sync-latest-release] stale: docs/public-surface-facts.json says ${currentMirror?.tag ?? "(missing)"}, GitHub says ${next.tag}`, |
| 89 | ); |
| 90 | } |
| 91 | console.error("Run: npm --prefix web run sync:latest-release && npm --prefix web run build"); |
| 92 | process.exit(1); |
| 93 | } |
| 94 | |
| 95 | writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`); |
| 96 | |
| 97 | if (!matrix) { |
| 98 | console.error(`[sync-latest-release] could not read ${mirror}; the mirror is now stale.`); |
| 99 | process.exit(1); |
| 100 | } |
| 101 | |
| 102 | // Preserve every key the matrix carries beyond the four synced fields (notably |
| 103 | // `sources`), so this stays a fact refresh and not a schema rewrite. |
| 104 | matrix.latestPublishedRelease = { ...currentMirror, ...next }; |
| 105 | writeFileSync(mirror, `${JSON.stringify(matrix, null, 2)}\n`); |
| 106 | |
| 107 | console.log(`[sync-latest-release] wrote ${next.tag} (${next.publishedAt}) to both facts`); |
| 108 |