From e304ff1e2fec91a4bbd71b12f0b69d7b9b14753b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 28 Jul 2026 14:26:08 +1000 Subject: [PATCH] docs: add an EQL pin resolver so patch bumps stop relying on memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports whether a newer EQL release exists for the pinned minor, and with --apply rewrites the pin. The companion workflow calls this on a schedule and opens a PR, so a new EQL release surfaces as a reviewable PR instead of something someone has to remember to check. It deliberately does NOT float the pin. The drift check validates every schema-qualified symbol in the hand-written pages against the pinned release's manifest and FAILS THE BUILD on a mismatch, so an auto-following pin would turn an unrelated PR red with no commit of its author's to explain it — which is what eql-3.0.0-alpha.4 did when it replaced eql_v3.ore_cllw. Scope is the pinned minor, and even that goes through review: EQL patch releases are not additive-only ahead of Stack 1.0. Between 3.0.0 and 3.0.1 — a patch — eql_v3.contains went 15 occurrences to 0, eql_v3.matches 0 to 15, and eql_v3_json_search 0 to 162. A newer minor or major is reported but never proposed, since those carry migration work beyond a pin change. Prereleases and the sibling tags cut by the same release (eql-bindings-v3.0.3, @cipherstash/eql@3.0.3) are excluded by matching eql-X.Y.Z exactly and honouring the API's prerelease flag. Note the release bodies claim "Preview (prerelease)" on stable releases; that text is stale boilerplate and contradicts the flag, so the flag is what this trusts. Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8 --- package.json | 1 + scripts/check-eql-pin.ts | 171 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 scripts/check-eql-pin.ts diff --git a/package.json b/package.json index b727a79..f763a54 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "generate-docs:eql": "tsx scripts/generate-eql-docs.ts", "generate-docs:eql-api": "tsx scripts/generate-eql-api-docs.ts", "generate-docs:cli": "tsx scripts/generate-cli-docs.ts", + "check-eql-pin": "tsx scripts/check-eql-pin.ts", "validate-links": "tsx scripts/validate-links.ts", "validate-redirects": "tsx scripts/validate-v2-redirects.ts", "validate-content": "tsx scripts/validate-content-api.ts", diff --git a/scripts/check-eql-pin.ts b/scripts/check-eql-pin.ts new file mode 100644 index 0000000..bb05235 --- /dev/null +++ b/scripts/check-eql-pin.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env tsx +/** + * Resolves whether a newer EQL release is available for the pinned minor. + * + * The EQL release is PINNED in `generate-eql-docs.ts` on purpose — see the + * header there. This script does not change that: it only reports (and with + * `--apply`, stages) the bump, so the upgrade still lands as a reviewable + * commit whose drift check has already run. + * + * Scope is deliberately the pinned MINOR. Patch releases in this project are + * not additive-only — 3.0.1 removed `eql_v3.contains`, added `eql_v3.matches`, + * and renamed the whole encrypted-JSON domain surface — so even a patch bump + * has to go through review. A newer minor or major is reported but never + * proposed, because those carry migration work beyond a pin change. + * + * Usage: + * tsx scripts/check-eql-pin.ts # report only, exit 0 + * tsx scripts/check-eql-pin.ts --apply # also rewrite the pin + */ +import fs from "node:fs"; +import path from "node:path"; + +const PIN_FILE = path.join(process.cwd(), "scripts/generate-eql-docs.ts"); +// Matches the pin's fallback literal, leaving the env-var override intact. +const PIN_RE = + /(EQL_RELEASE_TAG\s*=\s*process\.env\.EQL_RELEASE_TAG\s*\?\?\s*")(eql-[^"]+)(")/; +const RELEASES_API = + "https://api.github.com/repos/cipherstash/encrypt-query-language/releases"; +// Stable EQL SQL releases only. Excludes the sibling tags cut by the same +// release (`eql-bindings-v3.0.3`, `@cipherstash/eql@3.0.3`) and prereleases +// like `eql-3.0.0-alpha.4`, which must never be proposed automatically. +const TAG_RE = /^eql-(\d+)\.(\d+)\.(\d+)$/; + +interface Version { + tag: string; + major: number; + minor: number; + patch: number; +} + +function parseTag(tag: string): Version | null { + const m = TAG_RE.exec(tag); + if (!m) return null; + return { + tag, + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + }; +} + +function compare(a: Version, b: Version): number { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +function readPin(): Version { + const source = fs.readFileSync(PIN_FILE, "utf8"); + const m = PIN_RE.exec(source); + if (!m) { + throw new Error( + `Could not find the EQL_RELEASE_TAG pin in ${PIN_FILE}. ` + + "If the declaration was reformatted, update PIN_RE in this script.", + ); + } + const version = parseTag(m[2]); + if (!version) { + throw new Error( + `Pinned tag "${m[2]}" is not a stable eql-X.Y.Z release; refusing to compare.`, + ); + } + return version; +} + +async function fetchReleases(): Promise { + const headers: Record = { + accept: "application/vnd.github+json", + }; + // Authenticated in CI purely for the rate limit; the repo is public. + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (token) headers.authorization = `Bearer ${token}`; + + const found: Version[] = []; + // Each EQL release cuts three tags, so paginate rather than assume one page + // covers enough history. + for (let page = 1; page <= 3; page++) { + const res = await fetch(`${RELEASES_API}?per_page=100&page=${page}`, { + headers, + }); + if (!res.ok) { + throw new Error( + `GitHub releases API returned ${res.status} ${res.statusText}`, + ); + } + const batch = (await res.json()) as { + tag_name: string; + draft: boolean; + prerelease: boolean; + }[]; + if (!batch.length) break; + for (const r of batch) { + if (r.draft || r.prerelease) continue; + const v = parseTag(r.tag_name); + if (v) found.push(v); + } + if (batch.length < 100) break; + } + return found.sort(compare); +} + +function emit(name: string, value: string): void { + const out = process.env.GITHUB_OUTPUT; + if (out) fs.appendFileSync(out, `${name}=${value}\n`); +} + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + const pinned = readPin(); + const releases = await fetchReleases(); + + if (!releases.length) { + throw new Error( + "No stable eql-X.Y.Z releases found — refusing to proceed.", + ); + } + + const sameMinor = releases.filter( + (r) => r.major === pinned.major && r.minor === pinned.minor, + ); + const latestPatch = sameMinor[sameMinor.length - 1]; + const latestOverall = releases[releases.length - 1]; + + console.log(`Pinned: ${pinned.tag}`); + console.log( + `Latest in ${pinned.major}.${pinned.minor}.x: ${latestPatch?.tag ?? "(none)"}`, + ); + console.log(`Latest overall: ${latestOverall.tag}`); + + // A newer minor/major is surfaced but never proposed: it means migration + // work, not a pin bump, and deciding when to take it is a human call. + const newerLine = + compare(latestOverall, latestPatch ?? pinned) > 0 + ? `${latestOverall.tag} is available but is a minor/major bump — not proposed automatically.` + : ""; + if (newerLine) console.log(`\nNote: ${newerLine}`); + emit("newer_minor", newerLine); + + if (!latestPatch || compare(latestPatch, pinned) <= 0) { + console.log("\n✓ Pin is current for this minor. Nothing to do."); + emit("bump", "false"); + return; + } + + console.log(`\n→ Patch bump available: ${pinned.tag} → ${latestPatch.tag}`); + emit("bump", "true"); + emit("from", pinned.tag); + emit("to", latestPatch.tag); + + if (apply) { + const source = fs.readFileSync(PIN_FILE, "utf8"); + fs.writeFileSync( + PIN_FILE, + source.replace(PIN_RE, `$1${latestPatch.tag}$3`), + ); + console.log(` Applied to ${path.relative(process.cwd(), PIN_FILE)}`); + } +} + +main().catch((err) => { + console.error(`✗ ${err.message}`); + process.exit(1); +});