diff --git a/.github/scripts/vale-detect.cjs b/.github/scripts/vale-detect.cjs new file mode 100644 index 00000000..2bfb7048 --- /dev/null +++ b/.github/scripts/vale-detect.cjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Vale platform packages — upstream detection. + * + * The I/O half of the detect phase. It reads the latest upstream Vale release, + * hands it plus the committed manifest to `planManifestUpdate` (pure, in + * vale-release.cjs), and writes the rewritten manifest back when upstream is + * ahead. It publishes nothing and needs no npm credential. + * + * What bounds a run is that comparison, and only that comparison (design D5). A + * "is this version already on npm?" check could not do the job: every publish + * stamps a timestamp npm has never seen, so such a check would answer "not + * published" every single time and could never suppress anything. + * + * The two phases are separate because the trust boundary is code review. Detect + * proposes new digests; a human reviews them; merging the manifest change is + * what authorizes the publish run to fetch bytes matching those digests. A + * single job that discovered a digest and then verified against the digest it + * had just discovered would be verifying nothing. + * + * Usage: + * node .github/scripts/vale-detect.cjs [--write] + * + * --write rewrite vale-manifest.json in place when upstream is ahead. + * Without it the script only reports, which is what a local + * "what would this do?" run wants. + * + * Outputs (appended to $GITHUB_OUTPUT when set): + * update "true" when upstream is ahead + * vale_version the upstream version + * pinned_version the version currently in the manifest + */ + +const { appendFileSync, readFileSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const { + applyTemplate, + assertManifest, + isUpstreamAhead, + parseReleaseTag, + planManifestUpdate, + resolveChecksumsUrl, +} = require("./vale-release.cjs"); + +const MANIFEST_PATH = join(__dirname, "vale-manifest.json"); + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +/** + * GitHub's `releases/latest` deliberately excludes prereleases and drafts, so a + * Vale release candidate never trips detection. `GITHUB_TOKEN`, when present, + * is only for the API rate limit; the endpoint is public. + */ +async function fetchLatestTag(repository) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "taskless-skills-vale-detect", + }; + if (process.env.GITHUB_TOKEN) { + headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + const url = `https://api.github.com/repos/${repository}/releases/latest`; + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`GET ${url} responded ${response.status}`); + } + const release = await response.json(); + if (typeof release.tag_name !== "string") { + throw new TypeError(`${url} returned no tag_name`); + } + return release.tag_name; +} + +async function fetchText(url) { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`GET ${url} responded ${response.status}`); + } + return response.text(); +} + +async function main({ + argv = process.argv.slice(2), + latestTag = fetchLatestTag, + text = fetchText, +} = {}) { + const write = argv.includes("--write"); + const manifest = assertManifest( + JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) + ); + + const upstreamTag = await latestTag(manifest.upstream.repository); + console.log( + `pinned: ${manifest.valeVersion} upstream latest: ${upstreamTag}` + ); + + // Decide whether to go on with the two pure predicates directly, rather than + // by calling planManifestUpdate with a placeholder checksums payload. That + // shortcut looks equivalent but inverts the script: planManifestUpdate only + // ignores `checksumsText` on the NOT-ahead path, so a stand-in empty string + // makes it throw ("parsed to no entries") on exactly the runs that have + // something to propose. The cheap check has to be the cheap check. + const upstreamVersion = parseReleaseTag(upstreamTag); + if (!isUpstreamAhead(manifest.valeVersion, upstreamVersion)) { + console.log("Upstream is not ahead of the pinned version. Nothing to do."); + setOutput("update", "false"); + setOutput("vale_version", upstreamVersion); + setOutput("pinned_version", manifest.valeVersion); + return; + } + + // Only now is the checksums file worth downloading: it belongs to a release + // we are actually going to propose. + const checksumsUrl = resolveChecksumsUrl(manifest, upstreamVersion); + console.log(`fetching ${checksumsUrl}`); + const checksumsText = await text(checksumsUrl); + + const plan = planManifestUpdate({ manifest, upstreamTag, checksumsText }); + console.log( + `Upstream ${plan.upstreamVersion} is ahead of ${plan.pinnedVersion}.` + ); + for (const platform of plan.manifest.platforms) { + console.log( + ` ${applyTemplate(platform.asset, { version: plan.upstreamVersion })} ${platform.sha256}` + ); + } + + if (write) { + writeFileSync(MANIFEST_PATH, `${JSON.stringify(plan.manifest, null, 2)}\n`); + console.log(`\nRewrote ${MANIFEST_PATH}.`); + } else { + console.log("\nPass --write to update the manifest."); + } + + setOutput("update", "true"); + setOutput("vale_version", plan.upstreamVersion); + setOutput("pinned_version", plan.pinnedVersion); +} + +// Exported (and only self-invoking as a script) so vale-detect.test.cjs can run +// main() with the two fetches stubbed. The bug that motivated this was in the +// composition — how main() sequences pure functions that were each already +// tested — which is reachable no other way. +module.exports = { main }; + +if (require.main === module) { + main().catch((error) => { + console.error(`\nvale-detect failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/vale-detect.test.cjs b/.github/scripts/vale-detect.test.cjs new file mode 100644 index 00000000..ee66140f --- /dev/null +++ b/.github/scripts/vale-detect.test.cjs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Composition tests for vale-detect.cjs. + * + * vale-release.test.cjs covers the pure functions individually. This file + * covers the one thing that cannot: how main() sequences them, with both + * network calls stubbed. That gap is not hypothetical — the detect job once + * routed its cheap "is upstream ahead?" check through planManifestUpdate with + * an empty checksums payload, which throws on precisely the ahead path, so the + * job failed on every run that had a release to propose while the no-op path + * kept passing. Every function involved was green in isolation. + * + * Nothing here writes: main() is called without `--write`, so the committed + * manifest is only read. + */ + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { mkdtempSync, readFileSync, rmSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); + +const { main } = require("./vale-detect.cjs"); +const { applyTemplate } = require("./vale-release.cjs"); + +const MANIFEST = JSON.parse( + readFileSync(join(__dirname, "vale-manifest.json"), "utf8") +); + +/** A digest that is syntactically valid and obviously synthetic. */ +const digestFor = (index) => + String(index + 1) + .repeat(64) + .slice(0, 64); + +/** Upstream's sha256sum-format checksums file for a given Vale version. */ +function checksumsFor(version) { + return `${MANIFEST.platforms + .map( + (platform, index) => + `${digestFor(index)} ${applyTemplate(platform.asset, { version })}` + ) + .join("\n")}\n`; +} + +/** + * Run main() with both fetches stubbed and $GITHUB_OUTPUT pointed at a temp + * file, then return the parsed step outputs plus which URLs were fetched. + */ +async function runDetect({ upstreamTag, checksums }) { + const directory = mkdtempSync(join(tmpdir(), "vale-detect-test-")); + const outputPath = join(directory, "github-output"); + const previous = process.env.GITHUB_OUTPUT; + const fetched = []; + process.env.GITHUB_OUTPUT = outputPath; + try { + await main({ + argv: [], + latestTag: async () => upstreamTag, + text: async (url) => { + fetched.push(url); + return checksums; + }, + }); + const outputs = Object.fromEntries( + readFileSync(outputPath, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => { + const at = line.indexOf("="); + return [line.slice(0, at), line.slice(at + 1)]; + }) + ); + return { outputs, fetched }; + } finally { + if (previous === undefined) { + delete process.env.GITHUB_OUTPUT; + } else { + process.env.GITHUB_OUTPUT = previous; + } + rmSync(directory, { recursive: true, force: true }); + } +} + +test("detect: an upstream release ahead of the pin plans an update", async () => { + // The regression case. Before the fix this rejected with "upstream checksums + // file for 3.99.0 parsed to no entries" — the detect job's failure mode on + // every real upstream bump. + const { outputs, fetched } = await runDetect({ + upstreamTag: "v3.99.0", + checksums: checksumsFor("3.99.0"), + }); + + assert.equal(outputs.update, "true"); + assert.equal(outputs.vale_version, "3.99.0"); + assert.equal(outputs.pinned_version, MANIFEST.valeVersion); + assert.deepEqual(fetched, [ + `https://github.com/${MANIFEST.upstream.repository}/releases/download/v3.99.0/vale_3.99.0_checksums.txt`, + ]); +}); + +test("detect: the pinned version being current is a no-op", async () => { + const { outputs, fetched } = await runDetect({ + upstreamTag: `v${MANIFEST.valeVersion}`, + checksums: "", + }); + + assert.equal(outputs.update, "false"); + assert.equal(outputs.vale_version, MANIFEST.valeVersion); + assert.equal(outputs.pinned_version, MANIFEST.valeVersion); + // The whole reason the check is cheap: no checksums file is downloaded for a + // release we are not going to propose. + assert.deepEqual(fetched, []); +}); + +test("detect: an upstream tag behind the pin is also a no-op", async () => { + const { outputs, fetched } = await runDetect({ + upstreamTag: "v0.1.0", + checksums: "", + }); + + assert.equal(outputs.update, "false"); + assert.deepEqual(fetched, []); +}); + +test("detect: a checksums file missing a platform aborts", async () => { + await assert.rejects( + runDetect({ + upstreamTag: "v3.99.0", + checksums: checksumsFor("3.99.0").split("\n").slice(1).join("\n"), + }), + /publishes no asset named/ + ); +}); diff --git a/.github/scripts/vale-prepare.cjs b/.github/scripts/vale-prepare.cjs new file mode 100644 index 00000000..b841c998 --- /dev/null +++ b/.github/scripts/vale-prepare.cjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Vale platform packages — fetch, verify, unpack, stamp, pack. + * + * This is the I/O half of the publish phase. Every decision it makes is + * delegated to vale-release.cjs, which is pure and unit-tested; what lives here + * is only the network and filesystem work that cannot be. + * + * The order of operations is the point, and it is the order the requirement + * "verification occurs before any step holding publish credentials handles the + * binary" (design D6) demands: + * + * 1. download the upstream release ARCHIVE into a temporary directory; + * 2. sha256 it and compare against the digest committed in + * vale-manifest.json — abort the whole run on the first mismatch, before + * anything is unpacked, so unverified bytes are never even expanded onto + * disk; + * 3. unpack the single executable named by the manifest into its package + * directory at mode 0755; + * 4. stamp every package.json with one shared version; + * 5. `npm pack` each package into --out. + * + * Nothing here publishes, and nothing here needs a credential. The workflow + * runs it in a job that holds neither an npm identity nor an OIDC token, and + * hands the resulting tarballs to a separate credentialed job. That job then + * only ever sees bytes that already matched a reviewed digest and are already + * sealed into a tarball. + * + * Usage: + * node .github/scripts/vale-prepare.cjs [--out ] [--only ]... [--skip-pack] + * + * --out where to write the .tgz files (default: .vale-dist at the repo root) + * --only restrict to one platform package; repeatable. Accepts the full + * name (@taskless/vale-linux-x64) or the suffix (linux-x64). + * --skip-pack fetch, verify, unpack, and stamp, but do not run npm pack. + * + * Running this locally leaves the stamped version in each package.json and the + * unpacked executable in each package directory. Both are throwaway: the + * executable is gitignored, and checking the platform package.json files back + * out of git puts the placeholder version back. + */ + +const { createHash } = require("node:crypto"); +const { + chmodSync, + copyFileSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + appendFileSync, +} = require("node:fs"); +const { tmpdir } = require("node:os"); +const { basename, join, resolve, sep } = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const { + applyStamp, + assertChecksum, + assertManifest, + planStamp, + resolveAssetName, + resolveDownloadUrl, +} = require("./vale-release.cjs"); + +const REPO_ROOT = resolve(__dirname, "..", ".."); +const MANIFEST_PATH = join(__dirname, "vale-manifest.json"); + +/** + * Read the value that follows a flag, or throw. A missing value is a typo, and + * defaulting it would be worse than stopping: an empty `--out` resolves to the + * current working directory, so `--out` with its argument dropped would scatter + * tarballs wherever the script happened to be invoked from. + */ +function requireValue(argv, index, flag) { + const value = argv[index]; + if (value === undefined || value.length === 0 || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function parseArguments(argv) { + const options = { out: join(REPO_ROOT, ".vale-dist"), only: [], pack: true }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--out") { + index += 1; + options.out = resolve(requireValue(argv, index, "--out")); + } else if (argument === "--only") { + index += 1; + options.only.push(requireValue(argv, index, "--only")); + } else if (argument === "--skip-pack") { + options.pack = false; + } else { + throw new Error(`unknown argument: ${argument}`); + } + } + return options; +} + +/** Match `--only` against either the full package name or its suffix. */ +function selects(only, platform) { + if (only.length === 0) { + return true; + } + const suffix = platform.package.replace("@taskless/vale-", ""); + return only.some( + (entry) => + entry === platform.package || + entry === suffix || + entry === `vale-${suffix}` + ); +} + +function run(command, arguments_, options = {}) { + const result = spawnSync(command, arguments_, { + stdio: "inherit", + ...options, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} ${arguments_.join(" ")} exited with status ${result.status}` + ); + } + return result; +} + +/** Download to a file and return its sha256, without unpacking anything. */ +async function download(url, destinationPath) { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`GET ${url} responded ${response.status}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + writeFileSync(destinationPath, bytes); + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * Unpack exactly one member from an archive and place it at `destinationPath` + * with mode 0755. + * + * `tar` and `unzip` are used rather than a bundled extraction library because + * this file must stay dependency-free and both are present on the runner. The + * archive path and member name come from the reviewed manifest and are passed + * as argv (never through a shell), so neither can inject a command. + * + * The mode is set explicitly rather than inherited. Upstream's tarballs already + * carry `-rwxr-xr-x`, but a zip has no reliable Unix mode, and the executable + * bit surviving into the published tarball is a requirement rather than + * something to leave to the archive format. + * + * What lands in the temp directory is third-party bytes, so two things are + * checked before anything is copied into a package: the extracted path resolves + * inside the temp directory (a `../` member name cannot reach out of it), and it + * is a regular file rather than a symlink or directory. Without those, an + * archive carrying a symlink at the member's name would put a dangling link, or + * a link to a host path, into the published tarball. + * + * Both checks run after extraction, so they cover the member's LEAF entry and + * nothing above it. For a nested member the escape to worry about is an + * intermediate directory component that is itself a symlink out of the temp + * directory, which `tar` would follow while writing — before this function sees + * a path to inspect. What rules that out is upstream of here: `assertManifest` + * requires every `archiveMember` to be a flat single-segment filename, so there + * is no intermediate component to subvert. (GNU tar also refuses by default to + * follow a symlink when creating an implied directory, but that is the + * extractor's behavior, not this code's guarantee.) A future manifest entry + * needing a nested member would have to relax that assertion, and the checks + * below will not substitute for it. + */ +function unpackMember(archivePath, member, destinationPath) { + const workDirectory = mkdtempSync(join(tmpdir(), "vale-unpack-")); + try { + if (archivePath.endsWith(".zip")) { + run("unzip", ["-o", "-q", archivePath, member, "-d", workDirectory]); + } else { + run("tar", ["-xzf", archivePath, "-C", workDirectory, member]); + } + const root = resolve(workDirectory); + const extracted = resolve(root, member); + if (extracted === root || !extracted.startsWith(`${root}${sep}`)) { + throw new Error(`member ${member} resolves outside the unpack directory`); + } + const stats = lstatSync(extracted, { throwIfNoEntry: false }); + if (!stats) { + throw new Error( + `${basename(archivePath)} contains no member named ${member}` + ); + } + if (!stats.isFile()) { + throw new Error( + `member ${member} of ${basename(archivePath)} is not a regular file` + ); + } + copyFileSync(extracted, destinationPath); + chmodSync(destinationPath, 0o755); + } finally { + rmSync(workDirectory, { recursive: true, force: true }); + } +} + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const manifest = assertManifest( + JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) + ); + const selected = manifest.platforms.filter((platform) => + selects(options.only, platform) + ); + if (selected.length === 0) { + throw new Error(`--only matched no platform in ${MANIFEST_PATH}`); + } + + // One version for the whole set, computed once from the run's start time. + const plan = planStamp({ manifest, date: new Date() }); + console.log(`Vale ${manifest.valeVersion} → version ${plan.version}`); + + mkdirSync(options.out, { recursive: true }); + const downloadDirectory = mkdtempSync(join(tmpdir(), "vale-download-")); + const packed = []; + + try { + for (const platform of selected) { + const asset = resolveAssetName(manifest, platform); + const url = resolveDownloadUrl(manifest, platform); + const archivePath = join(downloadDirectory, asset); + + console.log(`\n${platform.package}`); + console.log(` fetch ${url}`); + const actual = await download(url, archivePath); + + // Fatal on mismatch: the loop stops here, nothing is unpacked, and no + // tarball reaches the credentialed job. + assertChecksum({ asset, expected: platform.sha256, actual }); + console.log(` verify sha256 ${actual}`); + + const packageDirectory = join(REPO_ROOT, platform.directory); + const binaryPath = join(packageDirectory, platform.archiveMember); + unpackMember(archivePath, platform.archiveMember, binaryPath); + console.log( + ` unpack ${platform.directory}/${platform.archiveMember} (0755)` + ); + + const packageJsonPath = join(packageDirectory, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + writeFileSync( + packageJsonPath, + `${JSON.stringify(applyStamp(packageJson, plan.version), null, 2)}\n` + ); + console.log(` stamp ${plan.version}`); + + if (options.pack) { + run( + "npm", + ["pack", "--ignore-scripts", "--pack-destination", options.out], + { cwd: packageDirectory } + ); + packed.push(platform.package); + } + } + } finally { + rmSync(downloadDirectory, { recursive: true, force: true }); + } + + console.log( + `\nPrepared ${selected.length} package(s) at ${plan.version}` + + (options.pack ? `; ${packed.length} tarball(s) in ${options.out}` : "") + ); + setOutput("version", plan.version); + setOutput("vale_version", manifest.valeVersion); +} + +main().catch((error) => { + console.error(`\nvale-prepare failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.github/scripts/vale-release.cjs b/.github/scripts/vale-release.cjs new file mode 100644 index 00000000..af79e34c --- /dev/null +++ b/.github/scripts/vale-release.cjs @@ -0,0 +1,540 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Vale platform packages — pure, zero-dependency release logic. + * + * Everything here is a total function over plain data: no network, no + * filesystem, no `process`. The I/O lives in the two sibling entry points that + * consume this module — + * + * vale-detect.cjs compares the latest upstream Vale release against the + * pinned one and rewrites the manifest for review. + * vale-prepare.cjs downloads, verifies, unpacks, stamps, and packs. + * + * — which keeps the parts worth testing (version stamping, semver assertions, + * digest comparison, manifest rewriting) directly unit-testable with + * `node --test` and no build step, the same arrangement as stack-breadcrumb.cjs. + * + * The design decisions these functions encode live in + * openspec/changes/add-vale-binary-packages/design.md; D4 (all-prerelease + * timestamp versioning) and D6 (committed checksums are the trust boundary) are + * the two that most of this file exists to enforce. + */ + +// --------------------------------------------------------------------------- +// Version shapes +// +// Two distinct kinds of version appear here and conflating them is the mistake +// this section exists to prevent: +// +// a VALE version plain `major.minor.patch`, e.g. `3.17.1` — what upstream +// released. Never itself published to npm by us. +// a STAMPED version `-`, e.g. +// `3.17.1-20260806120000` — what we publish, always. +// +// D4's four properties all follow from that second shape: provenance stays +// readable in the leading component, a packaging fix against the same upstream +// release gets a fresh timestamp rather than needing a version it does not own, +// the prerelease makes a caret range unable to resolve anything (see +// `rangeMatches`), and a 14-digit timestamp is a valid numeric prerelease +// identifier so ordering is numeric and monotonic. +// --------------------------------------------------------------------------- + +/** A plain upstream Vale version: `major.minor.patch`, no prerelease, no build. */ +const VALE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +/** A stamped package version: a Vale version plus a 14-digit UTC timestamp. */ +const STAMPED_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)-(\d{14})$/; + +/** + * The version committed for every platform package. It is a placeholder and is + * overwritten by `planStamp` before packing; it is never published as-is. Kept + * here so the assertion in `planStamp` and the scaffolding agree on one value. + */ +const PLACEHOLDER_VERSION = "0.0.0"; + +/** + * Parse a plain Vale version into its numeric components. Throws on anything + * that is not exactly `major.minor.patch` — including a version that already + * carries a prerelease, which would otherwise let a stamped version be stamped + * a second time and produce `3.17.1-2026…-2026…`. + */ +function parseValeVersion(text) { + const match = VALE_VERSION_PATTERN.exec(String(text ?? "")); + if (!match) { + throw new Error( + `not a plain Vale version (expected major.minor.patch): ${JSON.stringify(text)}` + ); + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +/** + * Strip the leading `v` from an upstream git tag (`v3.17.1` → `3.17.1`) and + * validate the remainder. Upstream tags its releases with the prefix; the + * manifest stores the bare version. + */ +function parseReleaseTag(tag) { + const text = String(tag ?? "").trim(); + const bare = text.startsWith("v") ? text.slice(1) : text; + parseValeVersion(bare); + return bare; +} + +/** + * Format a Date as the `yyyymmddhhmmss` UTC stamp. Derived from the ISO string + * so the UTC conversion is the platform's, not ours: `2026-08-06T12:00:00.000Z` + * → `20260806120000`. + */ +function formatStampTimestamp(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new TypeError("formatStampTimestamp requires a valid Date"); + } + return date.toISOString().replaceAll(/\D/g, "").slice(0, 14); +} + +/** + * Build the one version the whole set is published under: + * `-`. The result is run through + * `assertStampedVersion`, so a caller cannot get an unvalidated version out of + * here even by passing something strange in. + */ +function stampVersion(valeVersion, date) { + parseValeVersion(valeVersion); + const version = `${valeVersion}-${formatStampTimestamp(date)}`; + assertStampedVersion(version); + return version; +} + +/** + * Assert a version is a legitimate stamped version, and throw with a specific + * reason when it is not. Four things are checked, one per property in D4: + * + * 1. it matches `major.minor.patch-<14 digits>` at all, so a plain Vale + * version — the thing that must never be published — cannot pass; + * 2. the prerelease is a NUMERIC identifier under semver, meaning no leading + * zero. A leading zero would make semver treat it as alphanumeric and + * compare it lexically, breaking monotonic ordering; + * 3. that number is inside the safe-integer range, so the numeric comparison + * is exact; + * 4. the timestamp is a real calendar date, which catches a mangled stamp + * (`20261340…`) that would otherwise pass every syntactic check. + */ +function assertStampedVersion(version) { + const text = String(version ?? ""); + const match = STAMPED_VERSION_PATTERN.exec(text); + if (!match) { + throw new Error( + `not a stamped version (expected -): ${JSON.stringify(version)}` + ); + } + const timestamp = match[4]; + if (timestamp.startsWith("0")) { + throw new Error( + `timestamp ${timestamp} has a leading zero, so semver would compare it as a string rather than a number` + ); + } + const numeric = Number(timestamp); + if (!Number.isSafeInteger(numeric)) { + throw new Error(`timestamp ${timestamp} is outside the safe-integer range`); + } + const year = Number(timestamp.slice(0, 4)); + const month = Number(timestamp.slice(4, 6)); + const day = Number(timestamp.slice(6, 8)); + const hour = Number(timestamp.slice(8, 10)); + const minute = Number(timestamp.slice(10, 12)); + const second = Number(timestamp.slice(12, 14)); + const asDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + if (formatStampTimestamp(asDate) !== timestamp) { + throw new Error(`timestamp ${timestamp} is not a real UTC date and time`); + } + return text; +} + +/** + * Order two stamped versions by semver precedence. Both are prereleases of the + * form used here, so precedence is: compare `major.minor.patch` numerically, + * then compare the single numeric prerelease identifier numerically. Returns + * -1, 0, or 1. + */ +function compareStampedVersions(a, b) { + const left = STAMPED_VERSION_PATTERN.exec(assertStampedVersion(a)); + const right = STAMPED_VERSION_PATTERN.exec(assertStampedVersion(b)); + for (let index = 1; index <= 4; index += 1) { + const difference = Number(left[index]) - Number(right[index]); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + } + return 0; +} + +/** Order two plain Vale versions numerically. Returns -1, 0, or 1. */ +function compareValeVersions(a, b) { + const left = parseValeVersion(a); + const right = parseValeVersion(b); + for (const part of ["major", "minor", "patch"]) { + if (left[part] !== right[part]) { + return left[part] < right[part] ? -1 : 1; + } + } + return 0; +} + +/** True when upstream has released a Vale version newer than the pinned one. */ +function isUpstreamAhead(pinnedVersion, upstreamVersion) { + return compareValeVersions(upstreamVersion, pinnedVersion) > 0; +} + +/** + * Does a dependency range match a version, for the narrow subset of ranges D4 + * cares about: an exact version, `^x.y.z`, or `~x.y.z`. + * + * This is not a general semver resolver and is not used to resolve anything. It + * exists so D4's third property — "exact pinning is enforced by semver, not + * convention" — is asserted by a test rather than asserted in prose. It encodes + * the one semver rule that produces that property: + * + * a version carrying a prerelease satisfies a range only if some comparator + * in that range names the same major.minor.patch AND itself carries a + * prerelease. + * + * `^3.17.1` desugars to `>=3.17.1 <4.0.0`; neither comparator carries a + * prerelease, so `3.17.1-20260806120000` cannot satisfy it. Only the literal + * `3.17.1-20260806120000` does. That is what makes a consumer physically unable + * to float across published platform packages. + */ +function rangeMatches(range, version) { + const text = String(range ?? "").trim(); + const operator = text.startsWith("^") ? "^" : text.startsWith("~") ? "~" : ""; + const target = operator === "" ? text : text.slice(1); + + const stamped = STAMPED_VERSION_PATTERN.exec(String(version ?? "")); + const plain = VALE_VERSION_PATTERN.exec(String(version ?? "")); + if (!stamped && !plain) { + throw new Error(`unsupported version: ${JSON.stringify(version)}`); + } + + if (stamped) { + // The prerelease-exclusion rule. A caret or tilde range written over a + // plain Vale version has no prerelease anywhere in it, so it is out + // immediately; an exact range matches only when it is character-identical. + if (operator !== "") { + return false; + } + return target === String(version); + } + + // A plain version against a plain range: ordinary caret/tilde semantics, + // included only so the tests can contrast the two cases. + const rangeParts = parseValeVersion(target); + const versionParts = parseValeVersion(String(version)); + if (operator === "") { + return compareValeVersions(target, String(version)) === 0; + } + if (compareValeVersions(String(version), target) < 0) { + return false; + } + if (versionParts.major !== rangeParts.major) { + return false; + } + if (operator === "~" && versionParts.minor !== rangeParts.minor) { + return false; + } + if (operator === "^" && rangeParts.major === 0) { + // Caret below 1.0.0 narrows twice: `^0.y.z` allows the patch to float but + // pins the minor, and `^0.0.z` desugars to `>=0.0.z <0.0.(z+1)`, which is + // the single version itself. + if (rangeParts.minor === 0) { + return ( + versionParts.minor === 0 && versionParts.patch === rangeParts.patch + ); + } + return versionParts.minor === rangeParts.minor; + } + return true; +} + +// --------------------------------------------------------------------------- +// Manifest +// +// .github/scripts/vale-manifest.json pins the upstream Vale version and, per +// platform, the release asset, the member to unpack from it, and the SHA256 of +// the ARCHIVE. The digest covers the archive rather than the executable because +// that is what upstream publishes in vale__checksums.txt — so the +// committed value is checkable against upstream, and the archive is verified +// before anything is unpacked from it (D6). +// --------------------------------------------------------------------------- + +const MANIFEST_PLATFORM_FIELDS = [ + "package", + "directory", + "os", + "cpu", + "asset", + "archiveMember", + "sha256", +]; + +const SHA256_PATTERN = /^[\da-f]{64}$/; + +/** + * Validate a parsed manifest's shape and throw on the first problem. Called by + * both entry points before they do anything, so a hand-edit that drops a field + * fails immediately with a readable message rather than partway through a + * download loop. + */ +function assertManifest(manifest) { + if (typeof manifest !== "object" || manifest === null) { + throw new TypeError("manifest must be an object"); + } + parseValeVersion(manifest.valeVersion); + const upstream = manifest.upstream; + if (typeof upstream !== "object" || upstream === null) { + throw new Error("manifest.upstream is missing"); + } + for (const field of ["repository", "tag", "downloadUrl", "checksumsAsset"]) { + if (typeof upstream[field] !== "string" || upstream[field].length === 0) { + throw new Error(`manifest.upstream.${field} is missing`); + } + } + if (!Array.isArray(manifest.platforms) || manifest.platforms.length === 0) { + throw new Error("manifest.platforms must be a non-empty array"); + } + const seen = new Set(); + for (const platform of manifest.platforms) { + for (const field of MANIFEST_PLATFORM_FIELDS) { + if (typeof platform[field] !== "string" || platform[field].length === 0) { + throw new Error( + `manifest platform ${JSON.stringify(platform.package ?? "?")} is missing ${field}` + ); + } + } + // The containment checks in vale-prepare.cjs's unpackMember run after + // extraction, so they cover the member's LEAF entry only: an intermediate + // directory component that is itself a symlink pointing out of the unpack + // directory would be followed by tar while writing, before there is any + // path to inspect. Requiring a flat filename deletes that case rather than + // leaving it to the extractor's own symlink refusal — and keeps the + // requirement here, where a hand-edited manifest is rejected up front, + // instead of as a comment someone has to notice. + if ( + /[/\\]/.test(platform.archiveMember) || + platform.archiveMember === ".." + ) { + throw new Error( + `manifest platform ${platform.package} has an archiveMember that is not a flat filename: ${platform.archiveMember}` + ); + } + if (!SHA256_PATTERN.test(platform.sha256)) { + throw new Error( + `manifest platform ${platform.package} has a sha256 that is not 64 lowercase hex characters` + ); + } + if (seen.has(platform.package)) { + throw new Error(`manifest lists ${platform.package} more than once`); + } + seen.add(platform.package); + } + return manifest; +} + +/** Substitute `{version}` (and optionally `{asset}`) into a manifest template. */ +function applyTemplate(template, values) { + return String(template).replaceAll(/{(\w+)}/g, (whole, key) => + Object.hasOwn(values, key) ? String(values[key]) : whole + ); +} + +/** The resolved asset filename for a platform at the manifest's Vale version. */ +function resolveAssetName(manifest, platform) { + return applyTemplate(platform.asset, { version: manifest.valeVersion }); +} + +/** The full download URL for a platform's archive. */ +function resolveDownloadUrl(manifest, platform) { + return applyTemplate(manifest.upstream.downloadUrl, { + version: manifest.valeVersion, + asset: resolveAssetName(manifest, platform), + }); +} + +/** The full download URL for the upstream checksums file. */ +function resolveChecksumsUrl(manifest, version = manifest.valeVersion) { + return applyTemplate(manifest.upstream.downloadUrl, { + version, + asset: applyTemplate(manifest.upstream.checksumsAsset, { version }), + }); +} + +// --------------------------------------------------------------------------- +// Digest verification (D6) +// --------------------------------------------------------------------------- + +/** + * Parse an upstream `vale__checksums.txt` into a Map of asset name to + * digest. The format is the sha256sum one: `<64 hex> `, with the + * filename possibly prefixed by `*` for binary mode. + */ +function parseChecksumsFile(text) { + const digests = new Map(); + for (const line of String(text ?? "").split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + const match = /^([\da-f]{64})\s+\*?(.+)$/i.exec(trimmed); + if (!match) { + continue; + } + digests.set(match[2].trim(), match[1].toLowerCase()); + } + return digests; +} + +/** + * Compare a computed digest against the committed one and throw on a mismatch. + * The thrown message names the asset and both digests, because the operator + * reading a failed run needs to tell "upstream re-cut the release" apart from + * "the manifest was updated without re-running the fetch". + * + * Callers treat this as fatal: nothing downstream of a mismatch runs, which is + * what the requirement "checksum mismatch aborts the release" means in practice. + */ +function assertChecksum({ asset, expected, actual }) { + const want = String(expected ?? "").toLowerCase(); + const got = String(actual ?? "").toLowerCase(); + if (!SHA256_PATTERN.test(want)) { + throw new Error(`no committed sha256 for ${asset}`); + } + if (want !== got) { + throw new Error( + `sha256 mismatch for ${asset}\n committed: ${want}\n downloaded: ${got}\nRefusing to package or publish unverified bytes.` + ); + } + return true; +} + +// --------------------------------------------------------------------------- +// Detect phase +// --------------------------------------------------------------------------- + +/** + * Decide what the detect phase should do, given the pinned manifest, the latest + * upstream tag, and (when upstream is ahead) that release's checksums file. + * + * Returns `{ update: false, … }` when upstream is not ahead — the ONLY thing + * that bounds these runs. A published-version check could not: every publish + * stamps a timestamp npm has never seen, so it would report "not published" on + * every run and never suppress anything (D5). + * + * When upstream is ahead it returns the rewritten manifest, with `valeVersion` + * and every `sha256` taken from upstream's own checksums file and every other + * field left alone. A platform whose asset is absent from that file throws + * rather than silently carrying a stale digest forward, which would otherwise + * produce a manifest that fails verification only later, inside the publish run. + */ +function planManifestUpdate({ manifest, upstreamTag, checksumsText }) { + assertManifest(manifest); + const upstreamVersion = parseReleaseTag(upstreamTag); + if (!isUpstreamAhead(manifest.valeVersion, upstreamVersion)) { + return { + update: false, + pinnedVersion: manifest.valeVersion, + upstreamVersion, + manifest, + }; + } + + const digests = parseChecksumsFile(checksumsText); + if (digests.size === 0) { + throw new Error( + `upstream checksums file for ${upstreamVersion} parsed to no entries` + ); + } + + const platforms = manifest.platforms.map((platform) => { + const asset = applyTemplate(platform.asset, { version: upstreamVersion }); + const sha256 = digests.get(asset); + if (sha256 === undefined) { + throw new Error( + `upstream ${upstreamVersion} publishes no asset named ${asset} (needed by ${platform.package})` + ); + } + return { ...platform, sha256 }; + }); + + return { + update: true, + pinnedVersion: manifest.valeVersion, + upstreamVersion, + manifest: { ...manifest, valeVersion: upstreamVersion, platforms }, + }; +} + +// --------------------------------------------------------------------------- +// Stamp phase +// --------------------------------------------------------------------------- + +/** + * Plan the version write for every platform package: one version, computed + * once, applied identically across the set. Nothing here is per-platform, which + * is the point — six packages carrying the same upstream Vale build that + * disagreed about their version would make the CLI's exact pins unresolvable on + * some hosts and resolvable on others. + */ +function planStamp({ manifest, date }) { + assertManifest(manifest); + const version = stampVersion(manifest.valeVersion, date); + return { + version, + packages: manifest.platforms.map((platform) => ({ + package: platform.package, + directory: platform.directory, + version, + })), + }; +} + +/** + * Apply a stamped version to a parsed `package.json`, returning a new object. + * Refuses to stamp a package.json whose version is neither the committed + * placeholder nor an already-stamped version, so a hand-edit that put a real + * version into a platform package is caught rather than overwritten silently. + */ +function applyStamp(packageJson, version) { + assertStampedVersion(version); + const current = String(packageJson.version ?? ""); + if (current !== PLACEHOLDER_VERSION) { + assertStampedVersion(current); + } + return { ...packageJson, version }; +} + +module.exports = { + PLACEHOLDER_VERSION, + applyStamp, + applyTemplate, + assertChecksum, + assertManifest, + assertStampedVersion, + compareStampedVersions, + compareValeVersions, + formatStampTimestamp, + isUpstreamAhead, + parseChecksumsFile, + parseReleaseTag, + parseValeVersion, + planManifestUpdate, + planStamp, + rangeMatches, + resolveAssetName, + resolveChecksumsUrl, + resolveDownloadUrl, + stampVersion, +}; diff --git a/.github/scripts/vale-release.test.cjs b/.github/scripts/vale-release.test.cjs new file mode 100644 index 00000000..4c242fc8 --- /dev/null +++ b/.github/scripts/vale-release.test.cjs @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const { + PLACEHOLDER_VERSION, + applyStamp, + assertChecksum, + assertManifest, + assertStampedVersion, + compareStampedVersions, + compareValeVersions, + formatStampTimestamp, + isUpstreamAhead, + parseChecksumsFile, + parseReleaseTag, + parseValeVersion, + planManifestUpdate, + planStamp, + rangeMatches, + resolveAssetName, + resolveChecksumsUrl, + resolveDownloadUrl, + stampVersion, +} = require("./vale-release.cjs"); + +/** The manifest as committed, so these tests fail if it drifts out of shape. */ +const COMMITTED_MANIFEST = JSON.parse( + readFileSync(join(__dirname, "vale-manifest.json"), "utf8") +); + +const DIGEST_A = "a".repeat(64); +const DIGEST_B = "b".repeat(64); + +function fixtureManifest() { + return { + valeVersion: "3.17.1", + upstream: { + repository: "errata-ai/vale", + tag: "v{version}", + downloadUrl: + "https://github.com/errata-ai/vale/releases/download/v{version}/{asset}", + checksumsAsset: "vale_{version}_checksums.txt", + }, + platforms: [ + { + package: "@taskless/vale-linux-x64", + directory: "packages/vale-linux-x64", + os: "linux", + cpu: "x64", + asset: "vale_{version}_Linux_64-bit.tar.gz", + archiveMember: "vale", + sha256: DIGEST_A, + }, + { + package: "@taskless/vale-win32-x64", + directory: "packages/vale-win32-x64", + os: "win32", + cpu: "x64", + asset: "vale_{version}_Windows_64-bit.zip", + archiveMember: "vale.exe", + sha256: DIGEST_B, + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// Version parsing and stamping (tasks 4.1, 4.2) +// --------------------------------------------------------------------------- + +test("parseValeVersion: accepts a plain version, rejects a stamped one", () => { + assert.deepEqual(parseValeVersion("3.17.1"), { + major: 3, + minor: 17, + patch: 1, + }); + assert.throws(() => parseValeVersion("3.17.1-20260806120000"), /plain Vale/); + assert.throws(() => parseValeVersion("v3.17.1"), /plain Vale/); + assert.throws(() => parseValeVersion("3.17"), /plain Vale/); + assert.throws(() => parseValeVersion(undefined), /plain Vale/); +}); + +test("parseReleaseTag: strips the upstream v prefix", () => { + assert.equal(parseReleaseTag("v3.17.1"), "3.17.1"); + assert.equal(parseReleaseTag("3.17.1"), "3.17.1"); + assert.throws(() => parseReleaseTag("v3.17"), /plain Vale/); +}); + +test("formatStampTimestamp: 14 UTC digits, no separators", () => { + assert.equal( + formatStampTimestamp(new Date("2026-08-06T12:00:00.000Z")), + "20260806120000" + ); + // A non-UTC input is still stamped in UTC. + assert.equal( + formatStampTimestamp(new Date("2026-08-06T12:00:00.000+02:00")), + "20260806100000" + ); + assert.throws(() => formatStampTimestamp("2026-08-06"), TypeError); + assert.throws(() => formatStampTimestamp(new Date("nope")), TypeError); +}); + +test("stampVersion: produces -", () => { + assert.equal( + stampVersion("3.17.1", new Date("2026-08-06T12:00:00Z")), + "3.17.1-20260806120000" + ); +}); + +test("stampVersion: a bare Vale version can never be produced", () => { + // Every stamp carries a timestamp, so the output always contains a `-`. + for (const iso of [ + "2026-01-01T00:00:00Z", + "2026-08-06T12:34:56Z", + "2099-12-31T23:59:59Z", + ]) { + const version = stampVersion("3.17.1", new Date(iso)); + assert.notEqual(version, "3.17.1"); + assert.match(version, /^3\.17\.1-\d{14}$/); + } + // And the assertion itself refuses a bare version outright. + assert.throws(() => assertStampedVersion("3.17.1"), /not a stamped version/); +}); + +test("assertStampedVersion: rejects a leading-zero timestamp", () => { + // A leading zero makes semver treat the identifier as alphanumeric, which + // would compare lexically and break monotonic ordering. + assert.throws( + () => assertStampedVersion("3.17.1-02608061200000"), + /leading zero/ + ); +}); + +test("assertStampedVersion: rejects a timestamp that is not a real date", () => { + assert.throws( + () => assertStampedVersion("3.17.1-20261340120000"), + /not a real UTC date/ + ); +}); + +test("assertStampedVersion: rejects other prerelease shapes", () => { + assert.throws( + () => assertStampedVersion("3.17.1-taskless.1"), + /not a stamped version/ + ); + assert.throws( + () => assertStampedVersion("3.17.1-2026080612000"), + /not a stamped version/ + ); + assert.throws( + () => assertStampedVersion("3.17.1+20260806120000"), + /not a stamped version/ + ); +}); + +test("assertStampedVersion: the timestamp stays a safe integer", () => { + const version = assertStampedVersion("3.17.1-20260806120000"); + const timestamp = Number(version.split("-")[1]); + assert.ok(Number.isSafeInteger(timestamp)); +}); + +// --------------------------------------------------------------------------- +// Ordering (task 4.3) +// --------------------------------------------------------------------------- + +test("compareStampedVersions: two runs produce ordered versions", () => { + const earlier = stampVersion("3.17.1", new Date("2026-08-06T12:00:00Z")); + const later = stampVersion("3.17.1", new Date("2026-08-06T12:00:01Z")); + assert.equal(compareStampedVersions(earlier, later), -1); + assert.equal(compareStampedVersions(later, earlier), 1); + assert.equal(compareStampedVersions(later, later), 0); +}); + +test("compareStampedVersions: a newer Vale version outranks any timestamp", () => { + assert.equal( + compareStampedVersions("3.17.1-29991231235959", "3.17.2-20260101000000"), + -1 + ); +}); + +test("compareValeVersions and isUpstreamAhead", () => { + assert.equal(compareValeVersions("3.17.1", "3.17.2"), -1); + assert.equal(compareValeVersions("3.18.0", "3.17.9"), 1); + assert.equal(compareValeVersions("3.17.1", "3.17.1"), 0); + assert.equal(isUpstreamAhead("3.17.1", "3.17.2"), true); + assert.equal(isUpstreamAhead("3.17.1", "3.17.1"), false); + assert.equal(isUpstreamAhead("3.17.1", "3.17.0"), false); +}); + +// --------------------------------------------------------------------------- +// Range resolution (task 4.3 — D4 property 3) +// --------------------------------------------------------------------------- + +test("rangeMatches: a caret or tilde range over the Vale version matches nothing", () => { + const version = "3.17.1-20260806120000"; + assert.equal(rangeMatches("^3.17.1", version), false); + assert.equal(rangeMatches("~3.17.1", version), false); + assert.equal(rangeMatches("^3.0.0", version), false); + assert.equal(rangeMatches("~3.17.0", version), false); +}); + +test("rangeMatches: only the literal exact version resolves", () => { + const version = "3.17.1-20260806120000"; + assert.equal(rangeMatches(version, version), true); + assert.equal(rangeMatches("3.17.1", version), false); + assert.equal(rangeMatches("3.17.1-20260806120001", version), false); +}); + +test("rangeMatches: plain versions keep ordinary caret/tilde semantics", () => { + // The contrast that makes the prerelease exclusion above meaningful: the same + // ranges do match when the version carries no prerelease. + assert.equal(rangeMatches("^3.17.1", "3.18.0"), true); + assert.equal(rangeMatches("~3.17.1", "3.17.9"), true); + assert.equal(rangeMatches("~3.17.1", "3.18.0"), false); + assert.equal(rangeMatches("^3.17.1", "4.0.0"), false); + assert.equal(rangeMatches("^3.17.1", "3.17.0"), false); +}); + +test("rangeMatches: caret below 1.0.0 narrows the way semver narrows it", () => { + // `^0.y.z` pins the minor; `^0.0.z` pins everything. + assert.equal(rangeMatches("^0.2.1", "0.2.9"), true); + assert.equal(rangeMatches("^0.2.1", "0.3.0"), false); + assert.equal(rangeMatches("^0.0.1", "0.0.1"), true); + assert.equal(rangeMatches("^0.0.1", "0.0.2"), false); + assert.equal(rangeMatches("^0.0.1", "0.1.0"), false); +}); + +// --------------------------------------------------------------------------- +// Stamping the set (task 4.1, 4.3) +// --------------------------------------------------------------------------- + +test("planStamp: the whole set shares one version", () => { + const plan = planStamp({ + manifest: COMMITTED_MANIFEST, + date: new Date("2026-08-06T12:00:00Z"), + }); + assert.equal( + plan.version, + `${COMMITTED_MANIFEST.valeVersion}-20260806120000` + ); + assert.equal(plan.packages.length, 6); + assert.equal(new Set(plan.packages.map((p) => p.version)).size, 1); + for (const entry of plan.packages) { + assert.equal(entry.version, plan.version); + } +}); + +test("planStamp: covers every committed platform package", () => { + const plan = planStamp({ + manifest: COMMITTED_MANIFEST, + date: new Date("2026-08-06T12:00:00Z"), + }); + assert.deepEqual(plan.packages.map((p) => p.package).sort(), [ + "@taskless/vale-darwin-arm64", + "@taskless/vale-darwin-x64", + "@taskless/vale-linux-arm64", + "@taskless/vale-linux-x64", + "@taskless/vale-win32-arm64", + "@taskless/vale-win32-x64", + ]); +}); + +test("applyStamp: replaces the placeholder and re-stamps a stamped package", () => { + const stamped = applyStamp( + { name: "@taskless/vale-linux-x64", version: PLACEHOLDER_VERSION }, + "3.17.1-20260806120000" + ); + assert.equal(stamped.version, "3.17.1-20260806120000"); + assert.equal( + applyStamp(stamped, "3.17.1-20260806120001").version, + "3.17.1-20260806120001" + ); +}); + +test("applyStamp: refuses a package.json carrying an unexpected version", () => { + assert.throws( + () => applyStamp({ version: "1.2.3" }, "3.17.1-20260806120000"), + /not a stamped version/ + ); +}); + +// --------------------------------------------------------------------------- +// Manifest validation and templating +// --------------------------------------------------------------------------- + +test("assertManifest: the committed manifest is well formed", () => { + assert.doesNotThrow(() => assertManifest(COMMITTED_MANIFEST)); + assert.equal(COMMITTED_MANIFEST.platforms.length, 6); +}); + +test("assertManifest: every committed platform has a package directory entry", () => { + for (const platform of COMMITTED_MANIFEST.platforms) { + assert.equal( + platform.directory, + `packages/${platform.package.replace("@taskless/", "")}` + ); + } +}); + +test("assertManifest: rejects a missing field, a bad digest, and a duplicate", () => { + const missing = fixtureManifest(); + delete missing.platforms[0].archiveMember; + assert.throws(() => assertManifest(missing), /missing archiveMember/); + + const badDigest = fixtureManifest(); + badDigest.platforms[0].sha256 = "not-a-digest"; + assert.throws(() => assertManifest(badDigest), /64 lowercase hex/); + + const duplicate = fixtureManifest(); + duplicate.platforms[1].package = duplicate.platforms[0].package; + assert.throws(() => assertManifest(duplicate), /more than once/); + + const noUpstream = fixtureManifest(); + delete noUpstream.upstream.downloadUrl; + assert.throws(() => assertManifest(noUpstream), /upstream\.downloadUrl/); +}); + +test("assertManifest: an archiveMember must be a flat filename", () => { + // unpackMember's containment checks run after extraction and only cover the + // leaf entry, so a nested member would let a symlinked intermediate + // directory be followed by tar before there is a path to inspect. Keeping + // members flat is what removes that case, so it is asserted, not assumed. + for (const member of ["bin/vale", "../vale", "..", "a\\vale.exe"]) { + const nested = fixtureManifest(); + nested.platforms[0].archiveMember = member; + assert.throws(() => assertManifest(nested), /not a flat filename/); + } + + const flat = fixtureManifest(); + flat.platforms[0].archiveMember = "vale.exe"; + assert.equal(assertManifest(flat), flat); +}); + +test("resolve*: templates expand against the pinned version", () => { + const manifest = fixtureManifest(); + const [linux] = manifest.platforms; + assert.equal( + resolveAssetName(manifest, linux), + "vale_3.17.1_Linux_64-bit.tar.gz" + ); + assert.equal( + resolveDownloadUrl(manifest, linux), + "https://github.com/errata-ai/vale/releases/download/v3.17.1/vale_3.17.1_Linux_64-bit.tar.gz" + ); + assert.equal( + resolveChecksumsUrl(manifest), + "https://github.com/errata-ai/vale/releases/download/v3.17.1/vale_3.17.1_checksums.txt" + ); +}); + +// --------------------------------------------------------------------------- +// Digest verification (task 3.4) +// --------------------------------------------------------------------------- + +test("parseChecksumsFile: parses the upstream sha256sum format", () => { + const digests = parseChecksumsFile( + [ + `${DIGEST_A} vale_3.17.1_Linux_64-bit.tar.gz`, + `${DIGEST_B} *vale_3.17.1_Windows_64-bit.zip`, + "", + "garbage line that is not a digest", + ].join("\n") + ); + assert.equal(digests.size, 2); + assert.equal(digests.get("vale_3.17.1_Linux_64-bit.tar.gz"), DIGEST_A); + assert.equal(digests.get("vale_3.17.1_Windows_64-bit.zip"), DIGEST_B); +}); + +test("assertChecksum: a matching digest succeeds", () => { + assert.equal( + assertChecksum({ asset: "a.tar.gz", expected: DIGEST_A, actual: DIGEST_A }), + true + ); + // Case is normalized on both sides. + assert.equal( + assertChecksum({ + asset: "a.tar.gz", + expected: DIGEST_A.toUpperCase(), + actual: DIGEST_A, + }), + true + ); +}); + +test("assertChecksum: a mismatched digest aborts", () => { + assert.throws( + () => + assertChecksum({ + asset: "vale_3.17.1_Linux_64-bit.tar.gz", + expected: DIGEST_A, + actual: DIGEST_B, + }), + /sha256 mismatch for vale_3\.17\.1_Linux_64-bit\.tar\.gz[\S\s]*Refusing to package or publish/ + ); +}); + +test("assertChecksum: an absent committed digest aborts", () => { + assert.throws( + () => assertChecksum({ asset: "a.tar.gz", actual: DIGEST_A }), + /no committed sha256/ + ); +}); + +// --------------------------------------------------------------------------- +// Detect phase (tasks 5.2, 5.4) +// --------------------------------------------------------------------------- + +test("planManifestUpdate: upstream unchanged plans nothing", () => { + const result = planManifestUpdate({ + manifest: fixtureManifest(), + upstreamTag: "v3.17.1", + checksumsText: "", + }); + assert.equal(result.update, false); + assert.equal(result.upstreamVersion, "3.17.1"); +}); + +test("planManifestUpdate: an older upstream tag plans nothing", () => { + const result = planManifestUpdate({ + manifest: fixtureManifest(), + upstreamTag: "v3.17.0", + checksumsText: "", + }); + assert.equal(result.update, false); +}); + +test("planManifestUpdate: a newer upstream rewrites version and digests only", () => { + const manifest = fixtureManifest(); + const result = planManifestUpdate({ + manifest, + upstreamTag: "v3.18.0", + checksumsText: [ + `${DIGEST_B} vale_3.18.0_Linux_64-bit.tar.gz`, + `${DIGEST_A} vale_3.18.0_Windows_64-bit.zip`, + ].join("\n"), + }); + assert.equal(result.update, true); + assert.equal(result.pinnedVersion, "3.17.1"); + assert.equal(result.manifest.valeVersion, "3.18.0"); + assert.equal(result.manifest.platforms[0].sha256, DIGEST_B); + assert.equal(result.manifest.platforms[1].sha256, DIGEST_A); + // Asset templates are untouched, so they still carry {version}. + assert.equal( + result.manifest.platforms[0].asset, + "vale_{version}_Linux_64-bit.tar.gz" + ); + assert.equal(result.manifest.platforms[0].archiveMember, "vale"); + // And the input manifest is not mutated. + assert.equal(manifest.valeVersion, "3.17.1"); + assert.equal(manifest.platforms[0].sha256, DIGEST_A); + assert.doesNotThrow(() => assertManifest(result.manifest)); +}); + +test("planManifestUpdate: a platform missing from upstream's checksums aborts", () => { + assert.throws( + () => + planManifestUpdate({ + manifest: fixtureManifest(), + upstreamTag: "v3.18.0", + checksumsText: `${DIGEST_B} vale_3.18.0_Linux_64-bit.tar.gz`, + }), + /publishes no asset named vale_3\.18\.0_Windows_64-bit\.zip/ + ); +}); + +test("planManifestUpdate: an unparseable checksums file aborts", () => { + assert.throws( + () => + planManifestUpdate({ + manifest: fixtureManifest(), + upstreamTag: "v3.18.0", + checksumsText: "404: Not Found", + }), + /parsed to no entries/ + ); +}); diff --git a/.github/workflows/vale-binaries.yml b/.github/workflows/vale-binaries.yml new file mode 100644 index 00000000..63fb14ed --- /dev/null +++ b/.github/workflows/vale-binaries.yml @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: MIT +# Vale platform packages — detect upstream releases, then publish @taskless/vale-*. +# +# Standalone by design. This workflow does not touch release.yml and release.yml +# does not touch these packages: they are in the changesets `ignore` list, their +# versions are stamped here rather than bumped by a changeset, and release.yml's +# "is main's version on npm yet?" check reads packages/cli/package.json only. +# +# TWO PHASES, because the trust boundary is code review (design D6): +# +# detect Runs on a schedule with NO npm credential and NO OIDC identity. It +# compares the latest upstream Vale release against the version +# pinned in .github/scripts/vale-manifest.json. When upstream is +# ahead it opens a pull request that updates the pinned version and +# all six SHA256 digests, taken from upstream's own checksums file. +# It publishes nothing. +# +# publish Runs on the push to main that merges that pull request — i.e. once +# a human has reviewed the digests. Split further into `prepare` and +# `publish` below. +# +# A single job that discovered a digest and then verified downloads against the +# digest it had just discovered would verify nothing. Splitting the phases is +# what makes the automation trustworthy: nothing is published on bytes nobody +# signed off on, and nobody has to notice a Vale release for the process to run. +# +# WHAT BOUNDS A RUN is the upstream-version comparison, and only that. A "is +# this version already on npm?" check — the thing release.yml uses — cannot work +# here: every publish stamps -, a version npm has +# never seen, so such a check would answer "not published" every time and could +# never suppress anything (design D5). +# +# WHY prepare AND publish ARE SEPARATE JOBS: `prepare` downloads third-party +# bytes off the internet. It holds `contents: read`, no environment, and no +# id-token, so it cannot publish or mint a token no matter what it downloads. It +# verifies every archive against the committed digest, aborts the run on a +# mismatch before anything is unpacked, and hands over `npm pack` tarballs. The +# credentialed `publish` job therefore only ever handles bytes that already +# matched a reviewed digest and are already sealed into a tarball. It does not +# even check out the repository. +# +# WHY PACK BEFORE UPLOADING: actions/upload-artifact does not preserve file +# modes, and the Vale executable has to reach npm with its executable bit set. +# `npm pack` records modes inside the .tgz, so packing first and shipping the +# tarball through the artifact keeps 0755 intact end to end. +# +# PUBLISHING IDENTITY is inherited, not reinvented: npm trusted publishing, a +# short-lived OIDC-minted token bound to the `npm-production` environment, with +# no stored NPM_TOKEN anywhere. That binding is registered PER PACKAGE on +# npmjs.com and there is nothing to bind until the package name exists, so the +# FIRST publish of each of the six names is a deliberate one-time manual step by +# a maintainer, who then registers the trusted publisher. This workflow assumes +# that has already happened for every name in the manifest, and there is no +# fallback token path here on purpose. +# +# BOOTSTRAPPING A NEW PACKAGE NAME, once: +# +# node .github/scripts/vale-prepare.cjs --out .vale-dist +# npm publish --access public --tag latest .vale-dist/.tgz +# git checkout -- packages/vale-*/package.json # drop the local stamp +# +# Publish the packed tarball rather than the directory: the committed +# package.json carries the placeholder version 0.0.0 and no binary, so a bare +# `npm publish` in a package directory would burn the name on an empty 0.0.0. +# Provenance is omitted from the manual step (it needs a CI OIDC identity); +# register the trusted publisher afterwards and every later publish gets it. +# +# Action refs are pinned to commit SHAs; the trailing comment records the tag. + +name: Vale Binaries + +on: + # Detect only. Weekly is a deliberate cadence choice: a Vale security release + # should be mirrored faster than that, which is what the manual `detect` + # dispatch below is for. + schedule: + - cron: "23 7 * * 1" + + # Publish. This path fires exactly when a reviewed manifest change lands on + # main, which is the merge of a detect pull request. An ordinary push to main + # does not touch the manifest, so it starts no job here at all. + push: + branches: [main] + paths: + - ".github/scripts/vale-manifest.json" + + workflow_dispatch: + inputs: + phase: + description: "detect: check upstream and open a PR. publish: stamp and publish the pinned version." + type: choice + options: + - detect + - publish + default: detect + +# No workflow-wide grants; each job asks for exactly what it needs. +permissions: {} + +# One at a time, so a scheduled detect cannot race a publish. +concurrency: vale-binaries + +jobs: + detect: + name: Detect upstream Vale + if: >- + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && inputs.phase == 'detect') + runs-on: ubuntu-latest + permissions: + contents: write # push the vale/update- branch + pull-requests: write # open the manifest update PR + steps: + # Credentials persist here because this job pushes a branch. It holds no + # npm identity and no id-token, and it never runs downloaded code. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # No install step: the script is zero-dependency CommonJS. GITHUB_TOKEN is + # passed only to raise the GitHub API rate limit; the endpoints are public. + - id: detect + run: node .github/scripts/vale-detect.cjs --write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Every value reaching the shell below goes through `env:` rather than + # `${{ }}` interpolation into the script body. The version has already been + # validated as major.minor.patch by the script, but the pattern is the + # rule regardless of the value. + - name: Open the manifest update PR + if: steps.detect.outputs.update == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALE_VERSION: ${{ steps.detect.outputs.vale_version }} + PINNED_VERSION: ${{ steps.detect.outputs.pinned_version }} + run: | + branch="vale/update-${VALE_VERSION}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git diff --quiet -- .github/scripts/vale-manifest.json; then + echo "Manifest already pins ${VALE_VERSION}; nothing to propose." + exit 0 + fi + + git checkout -b "$branch" + git add .github/scripts/vale-manifest.json + git commit -m "chore(vale): pin Vale ${VALE_VERSION}" + git push --force origin "$branch" + + if [ -n "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then + echo "A pull request for $branch is already open; it now carries the current digests." + exit 0 + fi + + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore(vale): pin Vale ${VALE_VERSION}" \ + --body "$(cat <<'BODY' + Upstream Vale is ahead of the version this repository packages. + + This updates `.github/scripts/vale-manifest.json` to the new upstream + version and replaces every platform's SHA256 with the digest from + upstream's `vale__checksums.txt`. + + **Reviewing this is the trust boundary.** Merging it authorizes the + publish phase to download those exact archives and package them; a + download that does not match a digest here aborts the run and + publishes nothing. Check the digests against upstream's checksums file + for the release before approving. + + Merging this triggers the publish phase, which stamps every platform + package `-` and publishes the set. That + publish is inert on its own: `@taskless/cli` pins exact versions, so + nothing reaches a consumer until that pin is deliberately bumped. + BODY + )" \ + --label skip-changeset + + # Credential-free. Downloads third-party bytes, verifies them against the + # reviewed digests, and produces tarballs. Cannot publish anything. + prepare: + name: Fetch, verify, stamp, pack + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.phase == 'publish') + runs-on: ubuntu-latest + permissions: + contents: read # checkout only + outputs: + version: ${{ steps.prepare.outputs.version }} + vale_version: ${{ steps.prepare.outputs.vale_version }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false # nothing here writes to git + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # No dependency install at all: the script is zero-dependency CommonJS and + # unpacks with `tar` and `unzip`, both present on ubuntu-latest. Nothing + # from npm runs in this job beyond `npm pack` on our own packages. + - id: prepare + run: node .github/scripts/vale-prepare.cjs --out .vale-dist + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: vale-tarballs + path: .vale-dist/*.tgz + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish to npm + needs: prepare + runs-on: ubuntu-latest + # The scoping and audit boundary for the release, and where the npm trusted + # publisher for each @taskless/vale-* package is bound. + environment: npm-production + permissions: + contents: read + id-token: write # OIDC → short-lived npm auth + build provenance + steps: + # Deliberately no checkout. This job publishes tarballs the previous job + # already verified and sealed; it has no reason to hold repository source + # while an OIDC identity exists. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: vale-tarballs + path: tarballs + + # OIDC trusted publishing and provenance need npm >= 11.5.1. Pinned, not + # @latest, so publish behavior cannot change unreviewed. --ignore-scripts: + # no lifecycle code runs while the OIDC identity is available. + - run: npm install -g npm@12.0.1 --ignore-scripts + + # `--tag latest` is required, not cosmetic: every version here is a semver + # prerelease by design (D4), and npm refuses to publish a prerelease onto + # the default tag without being told. Tagging them `latest` is right — + # these are not preview builds, they are the only builds, and the + # prerelease component exists to defeat range matching, not to signal + # instability. + - name: Publish every platform tarball + working-directory: tarballs + env: + STAMPED_VERSION: ${{ needs.prepare.outputs.version }} + run: | + shopt -s nullglob + tarballs=(*.tgz) + if [ "${#tarballs[@]}" -eq 0 ]; then + echo "No tarballs in the artifact — refusing to report success." >&2 + exit 1 + fi + echo "Publishing ${#tarballs[@]} package(s) at ${STAMPED_VERSION}." + + # The loop deliberately does not stop at the first failure, and the + # step deliberately does not lean on `set -e` here. Six sequential + # publishes are six chances for a transient registry error, and + # aborting midway leaves the set partially released — some platforms + # resolvable at this version, others not, which is the one state the + # CLI's exact cross-package pins cannot tolerate. Attempting all six + # and failing at the end means one re-run has at most the stragglers + # left to do. + # + # That re-run is what the `npm view` guard is for: a tarball is + # immutable and its version is stamped once, in prepare, so a package + # already at this version was published by an earlier attempt of this + # same release. Skipping it is idempotent, where re-publishing would + # fail with "cannot publish over the previously published version" + # and strand every package after it. + failed=() + for tarball in "${tarballs[@]}"; do + echo "::group::$tarball" + name="$(tar -xzOf "$tarball" package/package.json | node -e 'let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; }).on("end", () => { console.log(JSON.parse(raw).name); });')" + if npm view "${name}@${STAMPED_VERSION}" version >/dev/null 2>&1; then + echo "${name}@${STAMPED_VERSION} is already published — skipping." + elif ! npm publish --provenance --access public --tag latest "./$tarball"; then + echo "::error::failed to publish ${name}@${STAMPED_VERSION}" + failed+=("$name") + fi + echo "::endgroup::" + done + + if [ "${#failed[@]}" -ne 0 ]; then + echo "Failed to publish ${#failed[@]} of ${#tarballs[@]} package(s) at ${STAMPED_VERSION}:" >&2 + printf ' %s\n' "${failed[@]}" >&2 + echo "Re-run this job to retry them; already-published packages are skipped." >&2 + exit 1 + fi + echo "All ${#tarballs[@]} package(s) are published at ${STAMPED_VERSION}." diff --git a/.gitignore b/.gitignore index 0b521c7c..d502dd69 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ node_modules/ /.claude/skills/taskless/ /.claude/commands/tskl/ +# Vale platform-package tarballs (npm pack output from vale-prepare.cjs) +.vale-dist/ + # Misc tmp # Auto-generated by dotagents — do not commit these files. diff --git a/openspec/changes/add-vale-binary-packages/tasks.md b/openspec/changes/add-vale-binary-packages/tasks.md index ddf2f9f0..3efa61f5 100644 --- a/openspec/changes/add-vale-binary-packages/tasks.md +++ b/openspec/changes/add-vale-binary-packages/tasks.md @@ -15,22 +15,22 @@ ## 3. Checksums and fetch - [x] 3.1 Commit a checksum manifest recording the SHA256 of each platform's upstream release asset for the pinned Vale version -- [ ] 3.2 Write the fetch step: download each platform asset, verify against the committed checksum, fail loudly on mismatch, and unpack the executable into its package directory preserving the executable bit -- [ ] 3.3 Ensure verification runs in a credential-free step, so no credentialed step handles unverified bytes -- [ ] 3.4 Tests: a mismatched checksum aborts and publishes nothing; a matching one yields an executable file in the expected location +- [x] 3.2 Write the fetch step: download each platform asset, verify against the committed checksum, fail loudly on mismatch, and unpack the executable into its package directory preserving the executable bit +- [x] 3.3 Ensure verification runs in a credential-free step, so no credentialed step handles unverified bytes +- [x] 3.4 Tests: a mismatched checksum aborts and publishes nothing; a matching one yields an executable file in the expected location ## 4. Version stamping -- [ ] 4.1 Write the stamping step: set every platform package to `-` (UTC), identically across the set -- [ ] 4.2 Assert the stamped version parses as a valid semver prerelease, that the timestamp is a numeric identifier with no leading zero, and that a plain `` is never produced -- [ ] 4.3 Tests: two runs produce ordered versions; the whole set shares one version; a caret range over the Vale version matches nothing +- [x] 4.1 Write the stamping step: set every platform package to `-` (UTC), identically across the set +- [x] 4.2 Assert the stamped version parses as a valid semver prerelease, that the timestamp is a numeric identifier with no leading zero, and that a plain `` is never produced +- [x] 4.3 Tests: two runs produce ordered versions; the whole set shares one version; a caret range over the Vale version matches nothing ## 5. Release workflow -- [ ] 5.1 Add a standalone workflow in two phases, no coupling to `release.yml`: **detect** — on a schedule, compare upstream Vale against what is published and open a PR updating the pinned version + checksums, publishing nothing; **publish** — on merge of that PR, run fetch → verify → stamp → pack → publish against the reviewed checksums -- [ ] 5.2 Bound runs by the upstream comparison, not a published-version check — a fresh timestamp is never already on npm, so that check can never suppress a run -- [ ] 5.3 Follow the existing hardening conventions: SHA-pinned action refs, no workflow-wide permission grants, no `${{ }}` interpolation of untrusted text into `run:`, OIDC trusted publishing bound to the `npm-production` environment, `--ignore-scripts` on install -- [ ] 5.4 Verify an ordinary push to `main` publishes no platform package, and that a run with upstream unchanged publishes nothing +- [x] 5.1 Add a standalone workflow in two phases, no coupling to `release.yml`: **detect** — on a schedule, compare upstream Vale against what is published and open a PR updating the pinned version + checksums, publishing nothing; **publish** — on merge of that PR, run fetch → verify → stamp → pack → publish against the reviewed checksums +- [x] 5.2 Bound runs by the upstream comparison, not a published-version check — a fresh timestamp is never already on npm, so that check can never suppress a run +- [x] 5.3 Follow the existing hardening conventions: SHA-pinned action refs, no workflow-wide permission grants, no `${{ }}` interpolation of untrusted text into `run:`, OIDC trusted publishing bound to the `npm-production` environment, `--ignore-scripts` on install +- [x] 5.4 Verify an ordinary push to `main` publishes no platform package, and that a run with upstream unchanged publishes nothing ## 6. CLI wiring @@ -40,6 +40,6 @@ ## 7. Quality gates -- [ ] 7.1 `pnpm typecheck && pnpm lint && pnpm test` clean at the repo root -- [ ] 7.2 Dry-run the release workflow end to end without publishing, and confirm the packed tarball contains the executable with its permission bit +- [x] 7.1 `pnpm typecheck && pnpm lint && pnpm test` clean at the repo root +- [x] 7.2 Dry-run the release workflow end to end without publishing, and confirm the packed tarball contains the executable with its permission bit - [ ] 7.3 Once published, remove tasks 5.1b–5.1e from `add-vale-rule-engine`, which reduces to the runtime resolution (its task 5.1) alone