diff --git a/.github/scripts/sg-detect.cjs b/.github/scripts/sg-detect.cjs new file mode 100644 index 00000000..2047daba --- /dev/null +++ b/.github/scripts/sg-detect.cjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * ast-grep — upstream detection. + * + * Answers one question: is the `@ast-grep/cli*` version pinned in + * packages/cli/package.json behind the version npm serves as `latest`? + * + * WHY THIS EXISTS AT ALL. Vale has had `vale-detect.cjs` since the platform + * packages were built, so a Vale release is noticed by a machine. ast-grep had + * nothing: the eight `@ast-grep/cli*` pins sat wherever someone last put them + * and nobody compared them to upstream. The `sg` badge therefore reports a + * number that had no watcher, which is the whole reason it is worth rendering. + * + * WHERE IT DIVERGES FROM vale-detect.cjs, and why the divergence is not + * carelessness: + * + * - Upstream is an npm DIST-TAG, not a GitHub release. ast-grep ships to npm, + * and what a consumer would get from `@ast-grep/cli@latest` is the honest + * definition of "what we are behind by". Reading GitHub releases instead + * would compare our pin against a tag that may not be on npm yet. + * + * - The pinned version is read from packages/cli/package.json, not from a + * manifest. There is no manifest to read: nothing here repackages ast-grep, + * so the dependency pins ARE the record of what we ship. + * + * - The version comparison is local rather than imported from + * vale-release.cjs. Its `parseValeVersion` would do the arithmetic + * correctly and then report `not a plain Vale version: "0.45.x"` when + * ast-grep publishes something unexpected — an error message that lies + * about which dependency is in trouble. Fifteen lines of comparator is a + * better trade than a wrong error and a coupling that makes a Vale-specific + * edit able to break this badge. + * + * Usage: + * node .github/scripts/sg-detect.cjs [--json] + * + * --json print `{ pinned, upstream, ahead }` and nothing else. This script + * never writes anything in either mode; unlike Vale there is no + * manifest to rewrite, and bumping eight dependency pins is a + * lockfile-touching change that belongs to a human. + * + * Outputs (appended to $GITHUB_OUTPUT when set): + * update "true" when upstream is ahead + * sg_version the upstream version + * pinned_version the version currently pinned in packages/cli/package.json + */ + +const { appendFileSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const PACKAGE_JSON_PATH = join( + __dirname, + "..", + "..", + "packages", + "cli", + "package.json" +); + +/** `@ast-grep/cli` itself and its per-platform siblings. */ +const PIN_PATTERN = /^@ast-grep\/cli(-|$)/; + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +const REGISTRY = "https://registry.npmjs.org"; + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +/** + * Parse `major.minor.patch`, rejecting anything else — including a range + * (`^0.41.0`) and a prerelease. Both are real states this can meet and neither + * has a defensible answer: a range means the pin is not a pin, and a + * prerelease on `latest` means upstream is doing something the badge should not + * quietly average over. + */ +function parseVersion(text, what) { + const match = VERSION_PATTERN.exec(String(text ?? "").trim()); + if (!match) { + throw new Error( + `${what} is not an exact major.minor.patch version: ${JSON.stringify(text)}` + ); + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** True when `upstream` is a newer version than `pinned`. */ +function isAhead(pinned, upstream) { + const left = parseVersion(pinned, "the pinned ast-grep version"); + const right = parseVersion(upstream, "the upstream ast-grep version"); + for (const [index, value] of right.entries()) { + if (value !== left[index]) { + return value > left[index]; + } + } + return false; +} + +/** + * The one version every `@ast-grep/cli*` dependency is pinned to. + * + * Disagreement among them is an error rather than a "pick the highest", + * because there is no version the badge could honestly show for a repository + * that pins two. That state is also a real bug — the platform packages are + * selected by optional dependency, so a straggler at an older version is a + * different ast-grep on one platform than on the others — and a badge that + * smoothed it over would hide exactly the drift it was added to expose. + */ +function collectPinnedVersion(packageJson) { + const pins = new Map(); + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]) { + for (const [name, range] of Object.entries(packageJson[field] ?? {})) { + if (PIN_PATTERN.test(name)) { + pins.set(name, range); + } + } + } + + if (pins.size === 0) { + throw new Error( + "packages/cli/package.json declares no @ast-grep/cli* dependency" + ); + } + + const versions = new Set(pins.values()); + if (versions.size > 1) { + const detail = [...pins] + .map(([name, range]) => `${name}@${range}`) + .sort() + .join(", "); + throw new Error( + `@ast-grep/cli* pins disagree, so there is no single version to report: ${detail}` + ); + } + + const [version] = versions; + parseVersion(version, "the pinned ast-grep version"); + return version; +} + +/** + * What `npm install @ast-grep/cli` would resolve to today. + * + * The abbreviated packument (`application/vnd.npm.install-v1+json`) is what npm + * itself asks for and is a small fraction of the full document, which carries + * every version's metadata. Both expose `dist-tags`, which is the only field + * read here. + * + * The scope separator is percent-encoded, as vale-gate.cjs does. The registry + * happens to serve `/@ast-grep/cli` unescaped today — that was checked against + * the real endpoint — but `/@scope%2Fname` is the documented form, it is what + * the rest of this directory uses, and relying on a redirect nobody promised is + * a strange thing to do to save one call. + */ +async function fetchLatestVersion(packageName) { + const url = `${REGISTRY}/${packageName.replace("/", "%2F")}`; + const response = await fetch(url, { + headers: { + accept: "application/vnd.npm.install-v1+json", + "user-agent": "taskless-skills-sg-detect", + }, + }); + if (!response.ok) { + throw new Error(`GET ${url} responded ${response.status}`); + } + const packument = await response.json(); + const latest = packument["dist-tags"]?.latest; + if (typeof latest !== "string") { + throw new TypeError(`${url} returned no dist-tags.latest`); + } + return latest; +} + +async function main({ + argv = process.argv.slice(2), + latestVersion = fetchLatestVersion, + packageJson = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8")), +} = {}) { + const json = argv.includes("--json"); + const log = json ? () => {} : (line) => console.log(line); + + const pinned = collectPinnedVersion(packageJson); + const upstream = await latestVersion("@ast-grep/cli"); + const ahead = isAhead(pinned, upstream); + + log(`pinned: ${pinned} upstream latest: ${upstream}`); + log( + ahead + ? `Upstream ${upstream} is ahead of ${pinned}. Bump every @ast-grep/cli* pin together.` + : "Upstream is not ahead of the pinned version. Nothing to do." + ); + + const comparison = { pinned, upstream, ahead }; + if (json) { + console.log(JSON.stringify(comparison)); + } + + setOutput("update", String(ahead)); + setOutput("sg_version", upstream); + setOutput("pinned_version", pinned); + return comparison; +} + +// main() both prints and RETURNS the comparison, so update-badges.cjs can call +// it in-process and read the answer as data. Nothing should ever parse the +// human line above to recover a version that this return value already holds. +module.exports = { collectPinnedVersion, isAhead, main }; + +if (require.main === module) { + main().catch((error) => { + console.error(`\nsg-detect failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/sg-detect.test.cjs b/.github/scripts/sg-detect.test.cjs new file mode 100644 index 00000000..383d8794 --- /dev/null +++ b/.github/scripts/sg-detect.test.cjs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Tests for sg-detect.cjs. + * + * The registry call is stubbed everywhere, so nothing here touches the network. + * The committed packages/cli/package.json is read once, on purpose: the "the + * repository's real pins are readable and exact" case is the one that fails + * silently in production if someone loosens a pin to a range, and a fixture + * would not notice. + */ + +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 { collectPinnedVersion, isAhead, main } = require("./sg-detect.cjs"); + +const CLI_PACKAGE_JSON = JSON.parse( + readFileSync( + join(__dirname, "..", "..", "packages", "cli", "package.json"), + "utf8" + ) +); + +/** Run main() with the registry stubbed and $GITHUB_OUTPUT captured. */ +async function runDetect({ upstream, packageJson, argv = [] }) { + const directory = mkdtempSync(join(tmpdir(), "sg-detect-test-")); + const outputPath = join(directory, "github-output"); + const previous = process.env.GITHUB_OUTPUT; + process.env.GITHUB_OUTPUT = outputPath; + try { + const comparison = await main({ + argv, + latestVersion: async () => upstream, + packageJson, + }); + 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 { comparison, outputs }; + } finally { + if (previous === undefined) { + delete process.env.GITHUB_OUTPUT; + } else { + process.env.GITHUB_OUTPUT = previous; + } + rmSync(directory, { recursive: true, force: true }); + } +} + +const pinnedAt = (version) => ({ + dependencies: { "@ast-grep/cli": version }, + optionalDependencies: { + "@ast-grep/cli-darwin-arm64": version, + "@ast-grep/cli-linux-x64-gnu": version, + }, +}); + +test("sg-detect: the repository's own pins are exact and agree", () => { + // Not a fixture. If someone changes a pin to `^0.41.0` or bumps one platform + // without the others, the badge has no honest value to show and this is + // where that is caught. + assert.match(collectPinnedVersion(CLI_PACKAGE_JSON), /^\d+\.\d+\.\d+$/); +}); + +test("sg-detect: an upstream release ahead of the pin is reported", async () => { + const { comparison, outputs } = await runDetect({ + upstream: "0.45.1", + packageJson: pinnedAt("0.41.0"), + }); + + assert.deepEqual(comparison, { + pinned: "0.41.0", + upstream: "0.45.1", + ahead: true, + }); + assert.equal(outputs.update, "true"); + assert.equal(outputs.sg_version, "0.45.1"); + assert.equal(outputs.pinned_version, "0.41.0"); +}); + +test("sg-detect: the pin being current is a no-op", async () => { + const { comparison, outputs } = await runDetect({ + upstream: "0.41.0", + packageJson: pinnedAt("0.41.0"), + }); + + assert.equal(comparison.ahead, false); + assert.equal(outputs.update, "false"); +}); + +test("sg-detect: an upstream version behind the pin is not ahead", async () => { + const { comparison } = await runDetect({ + upstream: "0.40.9", + packageJson: pinnedAt("0.41.0"), + }); + + assert.equal(comparison.ahead, false); +}); + +test("sg-detect: --json prints the comparison and nothing else", async () => { + const lines = []; + const original = console.log; + console.log = (line) => lines.push(line); + try { + await runDetect({ + upstream: "0.45.1", + packageJson: pinnedAt("0.41.0"), + argv: ["--json"], + }); + } finally { + console.log = original; + } + + assert.equal(lines.length, 1); + assert.deepEqual(JSON.parse(lines[0]), { + pinned: "0.41.0", + upstream: "0.45.1", + ahead: true, + }); +}); + +test("sg-detect: disagreeing pins abort rather than picking one", () => { + assert.throws( + () => + collectPinnedVersion({ + dependencies: { "@ast-grep/cli": "0.41.0" }, + optionalDependencies: { "@ast-grep/cli-darwin-arm64": "0.40.0" }, + }), + /pins disagree/ + ); +}); + +test("sg-detect: a range instead of an exact pin aborts", () => { + assert.throws( + () => collectPinnedVersion(pinnedAt("^0.41.0")), + /not an exact major\.minor\.patch/ + ); +}); + +test("sg-detect: no @ast-grep dependency at all aborts", () => { + assert.throws( + () => collectPinnedVersion({ dependencies: { typescript: "5.9.2" } }), + /declares no @ast-grep\/cli\* dependency/ + ); +}); + +test("sg-detect: an upstream prerelease on latest aborts loudly", () => { + // Better a failed run than a badge silently comparing 0.41.0 against + // something it cannot order. + assert.throws( + () => isAhead("0.41.0", "0.46.0-alpha.1"), + /upstream ast-grep version is not an exact major\.minor\.patch/ + ); +}); + +test("sg-detect: ordering is numeric, not lexical", () => { + assert.equal(isAhead("0.9.0", "0.10.0"), true); + assert.equal(isAhead("0.10.0", "0.9.0"), false); + assert.equal(isAhead("1.0.0", "0.99.99"), false); +}); diff --git a/.github/scripts/update-badges.cjs b/.github/scripts/update-badges.cjs new file mode 100644 index 00000000..cc5d4c20 --- /dev/null +++ b/.github/scripts/update-badges.cjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Write the shields endpoint payloads for the toolchain badges. + * + * `.shields/vale.json` and `.shields/sg.json` are served straight off + * raw.githubusercontent.com and rendered by + * `img.shields.io/endpoint?url=…`. Shields cannot ask "is there a newer + * upstream we have not pinned", so the color rule lives here, in code that is + * reviewed like everything else. + * + * THE MESSAGE CARRIES A DATE, AND THAT IS THE STALENESS SIGNAL. + * + * A badge that has stopped being updated looks exactly like a badge whose + * value has not changed — both render `vale | 3.17.1` forever. That is the + * failure this repository keeps hitting: an absent signal reading as a passing + * one. The fix has to be visible where the badge is read, because a + * `checkedAt` field buried in a JSON file nobody opens is a record, not a + * signal. So the message is ` · `, and a reader who sees + * a date from four months ago knows the job died rather than that upstream has + * been quiet. + * + * The date is deliberately NOT refreshed on every run, or the badge would be + * rewritten daily and every rewrite is a commit to `main` (see the loop note in + * update-badges.yml). It advances only once it is STALE_AFTER_DAYS old, so a + * daily schedule produces at most one commit a week per badge while still + * detecting an upstream release within a day of it happening. The date means + * "checked no earlier than this", which is the guarantee a reader needs, and + * the window of imprecision is bounded by the constant below. + * + * The payload is exactly the shields endpoint schema and carries no extra + * fields: the record and the signal are the same string, so they cannot drift + * apart, and there is no chance of shields rejecting an unrecognized key. + * + * Usage: + * node .github/scripts/update-badges.cjs [--write] + * + * --write rewrite the payloads whose rendered badge changed. Without it the + * script reports what it would do and touches nothing. + * + * Outputs (appended to $GITHUB_OUTPUT when set): + * changed "true" when at least one payload was (or would be) rewritten + */ + +const { + appendFileSync, + mkdirSync, + readFileSync, + writeFileSync, +} = require("node:fs"); +const { join } = require("node:path"); + +const { main: valeDetect } = require("./vale-detect.cjs"); +const { main: sgDetect } = require("./sg-detect.cjs"); + +const SHIELDS_DIRECTORY = join(__dirname, "..", "..", ".shields"); + +/** How old the recorded date may get before a run refreshes it. */ +const STALE_AFTER_DAYS = 7; + +const MESSAGE_SEPARATOR = " · "; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +/** `2026-08-21`, in UTC, so the date does not depend on where a runner is. */ +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +/** Whole days between two `YYYY-MM-DD` strings, both read as UTC midnight. */ +function daysBetween(earlier, later) { + return Math.round( + (Date.parse(`${later}T00:00:00Z`) - Date.parse(`${earlier}T00:00:00Z`)) / + 86_400_000 + ); +} + +/** + * Split `3.17.1 · 2026-08-21` back into its parts. Anything that does not have + * that shape — a hand-edited file, a payload written before this format, a + * truncated write — yields no date, which makes the next run rewrite it. That + * is the right bias: an unreadable badge should be replaced, not preserved. + */ +function parseMessage(message) { + const [version, date] = String(message ?? "").split(MESSAGE_SEPARATOR); + return { + version, + date: DATE_PATTERN.test(date ?? "") ? date : undefined, + }; +} + +/** + * The badge a comparison should render, given what is already committed. + * + * Pure, and the only place the color rule and the date rule live. Keeping the + * committed date when nothing else moved is what stops a daily schedule from + * committing daily; keeping it only while it is fresh is what stops the badge + * from claiming a check that never happened. + */ +function planBadge({ label, comparison, today, previous }) { + const color = comparison.ahead ? "yellow" : "green"; + const before = parseMessage(previous?.message); + + // A committed date in the FUTURE — a skewed runner clock, a hand edit — has + // a negative age, which `age < STALE_AFTER_DAYS` would read as freshly + // written and keep forever. That is the one way this rule could produce a + // badge that never self-corrects, so a negative age counts as stale. + const age = daysBetween(before.date ?? today, today); + const keepDate = + before.date !== undefined && + before.version === comparison.pinned && + previous?.color === color && + age >= 0 && + age < STALE_AFTER_DAYS; + + return { + schemaVersion: 1, + label, + message: `${comparison.pinned}${MESSAGE_SEPARATOR}${keepDate ? before.date : today}`, + color, + }; +} + +/** The committed payload, or undefined when there is not a readable one. */ +function readBadge(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return undefined; + } +} + +function serialize(badge) { + return `${JSON.stringify(badge, null, 2)}\n`; +} + +async function main({ + argv = process.argv.slice(2), + directory = SHIELDS_DIRECTORY, + now = new Date(), + detect = { + // --json keeps each detect script's human narration off stdout; the answer + // comes back as the return value rather than out of the printed line, so + // nothing here parses anything. + vale: () => valeDetect({ argv: ["--json"] }), + sg: () => sgDetect({ argv: ["--json"] }), + }, +} = {}) { + const write = argv.includes("--write"); + const today = formatDate(now); + + // Concurrent, because the two lookups are independent: one hits the GitHub + // releases API and the other the npm registry, and neither informs the other. + // Awaiting them in sequence inside the array literal made a scheduled run + // wait out both round trips end to end for no reason. + const [vale, sg] = await Promise.all([detect.vale(), detect.sg()]); + const badges = [ + { label: "vale", file: "vale.json", comparison: vale }, + { label: "sg", file: "sg.json", comparison: sg }, + ]; + + let changed = false; + for (const { label, file, comparison } of badges) { + const path = join(directory, file); + const previous = readBadge(path); + const badge = planBadge({ label, comparison, today, previous }); + const next = serialize(badge); + + if (previous !== undefined && serialize(previous) === next) { + console.log(`${file}: unchanged (${badge.message}, ${badge.color})`); + continue; + } + + changed = true; + console.log(`${file}: ${badge.message}, ${badge.color}`); + if (write) { + mkdirSync(directory, { recursive: true }); + writeFileSync(path, next); + } + } + + if (!write) { + console.log("\nPass --write to update the payloads."); + } + setOutput("changed", String(changed)); + return { changed }; +} + +module.exports = { main, planBadge }; + +if (require.main === module) { + main().catch((error) => { + console.error(`\nupdate-badges failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/update-badges.test.cjs b/.github/scripts/update-badges.test.cjs new file mode 100644 index 00000000..3071d669 --- /dev/null +++ b/.github/scripts/update-badges.test.cjs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * Tests for update-badges.cjs. + * + * Both detect scripts are stubbed and the payload directory is a temp one, so + * nothing here touches the network or the committed `.shields/` files. + * + * The rules under test are the two that are easy to get backwards: a run that + * finds nothing new must not rewrite anything (every rewrite becomes a commit + * to main, and a commit to main costs a Validate run and a nightly publish), + * and the date must not be allowed to sit still forever, or a dead job renders + * as a healthy one. + */ + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); + +const { main, planBadge } = require("./update-badges.cjs"); + +const { isAhead } = require("./sg-detect.cjs"); + +/** + * A stubbed detect result, with `ahead` computed the way production computes + * it. `pinned !== upstream` was close enough for every case here, but it would + * have mismodelled the one that matters — upstream BEHIND the pin, which is + * `ahead: false` and must stay green — so a later test could have asserted + * against a comparison the real scripts can never produce. + */ +const comparisonOf = (pinned, upstream) => ({ + pinned, + upstream, + ahead: isAhead(pinned, upstream), +}); + +/** Run main() against a temp directory seeded with `files`. */ +async function run({ files = {}, vale, sg, now, argv = ["--write"] }) { + const directory = mkdtempSync(join(tmpdir(), "update-badges-test-")); + const silenced = console.log; + console.log = () => {}; + try { + for (const [name, contents] of Object.entries(files)) { + writeFileSync(join(directory, name), contents); + } + const result = await main({ + argv, + directory, + now: new Date(now), + detect: { vale: async () => vale, sg: async () => sg }, + }); + const read = (name) => { + const path = join(directory, name); + return existsSync(path) + ? JSON.parse(readFileSync(path, "utf8")) + : undefined; + }; + return { ...result, vale: read("vale.json"), sg: read("sg.json") }; + } finally { + console.log = silenced; + rmSync(directory, { recursive: true, force: true }); + } +} + +const badge = (message, color) => + `${JSON.stringify({ schemaVersion: 1, label: "vale", message, color }, null, 2)}\n`; + +test("badges: an empty .shields writes both payloads", async () => { + const result = await run({ + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.changed, true); + // The live case this shipped against: Vale pinned at 3.17.1 with 3.18.0 + // upstream is yellow, ast-grep level with upstream is green. + assert.deepEqual(result.vale, { + schemaVersion: 1, + label: "vale", + message: "3.17.1 · 2026-08-21", + color: "yellow", + }); + assert.deepEqual(result.sg, { + schemaVersion: 1, + label: "sg", + message: "0.41.0 · 2026-08-21", + color: "green", + }); +}); + +test("badges: nothing new upstream writes nothing", async () => { + const result = await run({ + files: { + "vale.json": badge("3.17.1 · 2026-08-19", "yellow"), + "sg.json": badge("0.41.0 · 2026-08-19", "green").replace( + '"vale"', + '"sg"' + ), + }, + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.changed, false); + // Two days on, the recorded date is still the truth it was written with. + assert.equal(result.vale.message, "3.17.1 · 2026-08-19"); +}); + +test("badges: a date older than the staleness horizon is refreshed", async () => { + const result = await run({ + files: { "vale.json": badge("3.17.1 · 2026-08-01", "yellow") }, + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.changed, true); + assert.equal(result.vale.message, "3.17.1 · 2026-08-21"); +}); + +test("badges: bumping the pin turns the badge green and redates it", async () => { + const result = await run({ + files: { "vale.json": badge("3.17.1 · 2026-08-20", "yellow") }, + vale: comparisonOf("3.18.0", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.deepEqual(result.vale, { + schemaVersion: 1, + label: "vale", + message: "3.18.0 · 2026-08-21", + color: "green", + }); +}); + +test("badges: without --write nothing is written but the answer is reported", async () => { + const result = await run({ + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.45.1"), + now: "2026-08-21T06:17:00Z", + argv: [], + }); + + assert.equal(result.changed, true); + assert.equal(result.vale, undefined); + assert.equal(result.sg, undefined); +}); + +test("badges: an unreadable payload is replaced rather than preserved", async () => { + const result = await run({ + files: { "vale.json": "{ not json" }, + vale: comparisonOf("3.17.1", "3.18.0"), + sg: comparisonOf("0.41.0", "0.41.0"), + now: "2026-08-21T06:17:00Z", + }); + + assert.equal(result.vale.message, "3.17.1 · 2026-08-21"); +}); + +test("badges: the payload carries only the shields endpoint schema", () => { + // No `checkedAt` sidecar. Shields validates the payload it fetches, and a + // record nobody reads is not a staleness signal anyway — the date is in the + // message for exactly that reason. + const planned = planBadge({ + label: "vale", + comparison: comparisonOf("3.17.1", "3.18.0"), + today: "2026-08-21", + previous: undefined, + }); + + assert.deepEqual(Object.keys(planned), [ + "schemaVersion", + "label", + "message", + "color", + ]); +}); + +test("badges: an upstream version behind the pin stays green", () => { + // The case the old test helper could not express: `pinned !== upstream` is + // true here, but nothing is ahead of us and the badge must not go yellow. + const planned = planBadge({ + label: "sg", + comparison: comparisonOf("0.45.1", "0.41.0"), + today: "2026-08-22", + previous: undefined, + }); + + assert.equal(planned.color, "green"); +}); + +test("badges: a date in the future is treated as stale, not as fresh", () => { + // A skewed clock or a hand edit must not produce a date that outlives every + // later run. A negative age is stale. + const planned = planBadge({ + label: "vale", + comparison: comparisonOf("3.17.1", "3.18.0"), + today: "2026-08-22", + previous: { + schemaVersion: 1, + label: "vale", + message: "3.17.1 · 2027-01-01", + color: "yellow", + }, + }); + + assert.equal(planned.message, "3.17.1 · 2026-08-22"); +}); + +test("badges: a payload written the same day is left alone", () => { + const previous = { + schemaVersion: 1, + label: "sg", + message: "0.41.0 · 2026-08-21", + color: "green", + }; + + assert.deepEqual( + planBadge({ + label: "sg", + comparison: comparisonOf("0.41.0", "0.41.0"), + today: "2026-08-21", + previous, + }), + previous + ); +}); diff --git a/.github/scripts/vale-detect.cjs b/.github/scripts/vale-detect.cjs index 2bfb7048..60a2ba60 100644 --- a/.github/scripts/vale-detect.cjs +++ b/.github/scripts/vale-detect.cjs @@ -22,12 +22,22 @@ * had just discovered would be verifying nothing. * * Usage: - * node .github/scripts/vale-detect.cjs [--write] + * node .github/scripts/vale-detect.cjs [--write] [--json] * * --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. * + * --json print `{ pinned, upstream, ahead }` and nothing else, then stop + * before the checksums download. update-badges.cjs needs the + * comparison and nothing else, and the alternative — scraping the + * "pinned: X upstream latest: vY" line this script prints for a + * human — would rebuild, with a regex, a fact this script already + * holds as data. Implies read-only: --json never writes the + * manifest, because the badge run is not the run that proposes a + * pin, and skipping the checksums fetch is not a shortcut but the + * point (nothing is being verified here). + * * Outputs (appended to $GITHUB_OUTPUT when set): * update "true" when upstream is ahead * vale_version the upstream version @@ -93,15 +103,23 @@ async function main({ latestTag = fetchLatestTag, text = fetchText, } = {}) { + const json = argv.includes("--json"); const write = argv.includes("--write"); + // --json is a reporting mode and --write is a writing one. Refusing the + // combination beats silently dropping whichever flag loses, since the caller + // that passed both is wrong about what it is asking for. + if (json && write) { + throw new Error("--json is read-only; it cannot be combined with --write"); + } + // Everything a human wants to read is noise on stdout when a caller is + // reading structured output from it. + const log = json ? () => {} : (line) => console.log(line); const manifest = assertManifest( JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) ); const upstreamTag = await latestTag(manifest.upstream.repository); - console.log( - `pinned: ${manifest.valeVersion} upstream latest: ${upstreamTag}` - ); + 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 @@ -110,12 +128,30 @@ async function main({ // 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."); + const ahead = isUpstreamAhead(manifest.valeVersion, upstreamVersion); + + // The structured answer, which is all a --json caller wanted. Reported here + // rather than after the checksums fetch below: that download exists to + // propose a manifest, and a badge run proposes nothing. + const comparison = { + pinned: manifest.valeVersion, + upstream: upstreamVersion, + ahead, + }; + if (json) { + console.log(JSON.stringify(comparison)); + setOutput("update", String(ahead)); + setOutput("vale_version", upstreamVersion); + setOutput("pinned_version", manifest.valeVersion); + return comparison; + } + + if (!ahead) { + 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; + return comparison; } // Only now is the checksums file worth downloading: it belongs to a release @@ -144,6 +180,7 @@ async function main({ setOutput("update", "true"); setOutput("vale_version", plan.upstreamVersion); setOutput("pinned_version", plan.pinnedVersion); + return comparison; } // Exported (and only self-invoking as a script) so vale-detect.test.cjs can run diff --git a/.github/scripts/vale-detect.test.cjs b/.github/scripts/vale-detect.test.cjs index ee66140f..d0a6c070 100644 --- a/.github/scripts/vale-detect.test.cjs +++ b/.github/scripts/vale-detect.test.cjs @@ -49,15 +49,15 @@ function checksumsFor(version) { * 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 }) { +async function runDetect({ upstreamTag, checksums, argv = [] }) { 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: [], + const comparison = await main({ + argv, latestTag: async () => upstreamTag, text: async (url) => { fetched.push(url); @@ -73,7 +73,7 @@ async function runDetect({ upstreamTag, checksums }) { return [line.slice(0, at), line.slice(at + 1)]; }) ); - return { outputs, fetched }; + return { comparison, outputs, fetched }; } finally { if (previous === undefined) { delete process.env.GITHUB_OUTPUT; @@ -125,6 +125,48 @@ test("detect: an upstream tag behind the pin is also a no-op", async () => { assert.deepEqual(fetched, []); }); +test("detect: --json reports the comparison without fetching checksums", async () => { + // What update-badges.cjs consumes. It takes the RETURN value rather than the + // printed line, but both are asserted here: the printed line is the contract + // for anything calling the script from a shell. + const lines = []; + const original = console.log; + console.log = (line) => lines.push(line); + let result; + try { + result = await runDetect({ + upstreamTag: "v3.99.0", + checksums: "", + argv: ["--json"], + }); + } finally { + console.log = original; + } + + assert.deepEqual(result.comparison, { + pinned: MANIFEST.valeVersion, + upstream: "3.99.0", + ahead: true, + }); + assert.equal(lines.length, 1); + assert.deepEqual(JSON.parse(lines[0]), result.comparison); + // The badge needs the comparison, not the digests, so the ahead path stops + // before the download the manifest proposal would need. + assert.deepEqual(result.fetched, []); + assert.equal(result.outputs.update, "true"); +}); + +test("detect: --json refuses to be combined with --write", async () => { + await assert.rejects( + runDetect({ + upstreamTag: "v3.99.0", + checksums: "", + argv: ["--json", "--write"], + }), + /--json is read-only/ + ); +}); + test("detect: a checksums file missing a platform aborts", async () => { await assert.rejects( runDetect({ diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml new file mode 100644 index 00000000..05d72146 --- /dev/null +++ b/.github/workflows/update-badges.yml @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: MIT +# Refresh the shields endpoint payloads for the toolchain badges. +# +# `.shields/vale.json` and `.shields/sg.json` are the only badge values this +# repository computes for itself. Shields cannot answer "is there a newer +# upstream we have not pinned", so the comparison runs here and the color rule +# lives in `.github/scripts/update-badges.cjs`, under code review like anything +# else. Everything else in the README's badge row is a shields built-in or a +# static badge and needs nothing from this file. +# +# WHY THIS IS ITS OWN WORKFLOW AND NOT A JOB IN A PUBLISHING ONE. +# +# It needs `contents: write`, because writing a badge means committing to +# `main`. `release-cli.yml`, `release-cli-nightly.yml`, and `release-vale.yml` +# each hold `id-token: write` and an npm OIDC publishing identity. Keeping the +# two apart in SEPARATE FILES makes the separation structural: there is no +# arrangement of jobs here that a later edit could get wrong, because the +# credentialed workflows are not in this file to be edited. A job added to a +# release workflow would put a write token and a publishing identity one +# `permissions:` block away from each other, and the only thing keeping them +# apart would be whoever reviews the next change to that file. Do not merge +# this into a release workflow to save a file. +# +# WHY IT IS NOT FOLDED INTO release-vale.yml SPECIFICALLY. That workflow's +# detect phase opens a pull request when upstream Vale is ahead. The badge has +# to be YELLOW while that pull request is open and green once it merges, so the +# payload cannot live in that pull request's own diff — it would go yellow at +# exactly the moment it should go green. It has to reach `main` independently +# of the change it is warning about. +# +# WHY THE SCHEDULE, AND WHY A BADGE COMMIT DOES NOT LOOP. +# +# `validate.yml` triggers on every push to `main` with NO `paths` filter, and +# since #132 the nightly publishes on `Validate` completing. So a commit here +# costs one Validate run and, if changesets are pending, one nightly publish. +# That chain terminates only because what is committed depends on UPSTREAM +# moving, not on us pushing: +# +# badge commit → Validate → nightly workflow_run → nightly publishes +# → next scheduled badge run finds the same upstream versions +# → writes nothing → no commit → chain over. +# +# A badge derived from each nightly would NOT terminate: publish → commit → +# Validate → publish, forever. The nightly gate cannot stop it, because it +# dedupes per-sha and every badge commit is a new sha. That is why the nightly +# badge in the README is a plain static one with no version in it, and why +# nothing in this workflow ever reads a nightly version. +# +# The same reasoning bounds the cost of this workflow. The schedule is daily so +# an upstream release is noticed within a day, but `update-badges.cjs` rewrites +# a payload only when the rendered badge actually changes — and the date it +# renders advances only once a week — so a quiet week produces at most one +# commit per badge, not seven. +# +# THE DATE IN THE MESSAGE IS THE POINT, NOT DECORATION. A badge that stopped +# being updated looks identical to one whose value has not changed. Rendering +# `vale | 3.17.1 · 2026-08-21` means a reader can tell "upstream has been +# quiet" from "this job died in April" without opening anything. +# +# Action refs are pinned to commit SHAs; the trailing comment records the tag. + +name: Update badges + +on: + # Upstream's release cadence, not ours. This answers "what is upstream + # doing", which has nothing to do with our own pushes or releases — hence a + # schedule rather than a trigger on any repository event. + schedule: + - cron: "17 6 * * *" + + # For "upstream just released, do not wait until tomorrow". + workflow_dispatch: + +# No workflow-wide grants; the one job asks for exactly what it needs. +permissions: {} + +# One at a time, so two runs cannot both decide to commit the same payload. +concurrency: update-badges + +jobs: + update: + name: Refresh toolchain badges + runs-on: ubuntu-latest + permissions: + contents: write # commit the refreshed payloads to main + steps: + # Credentials persist because this job pushes. It holds no npm identity, + # no id-token, and runs nothing it downloaded. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # No install step: the scripts are zero-dependency CommonJS. GITHUB_TOKEN + # is passed only to raise the GitHub API rate limit on the Vale release + # lookup; both endpoints (GitHub releases, the npm registry) are public. + - id: badges + run: node .github/scripts/update-badges.cjs --write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Guarded on the script's own answer rather than on `git diff`, so the + # decision is made in the tested code path rather than restated in shell. + # Nothing from a workflow expression is interpolated into this body. + # + # RETRIED, because losing a race here is the ordinary case rather than an + # exceptional one: any pull request merging between the checkout and this + # push makes `main` a non-fast-forward, and a bare push would fail the job + # and leave the badge stale until tomorrow's schedule. Rebasing our single + # commit onto the new tip and pushing again is exactly what a human would + # do, and `.shields/` is touched by nothing else, so the rebase cannot + # meet a conflict from an ordinary merge. + # + # Bounded at three attempts. A push that keeps losing is not a race any + # more, and a job that retries forever hides whatever is actually wrong. + - name: Commit the refreshed payloads + if: steps.badges.outputs.changed == 'true' + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .shields + git commit -m "chore(badges): refresh upstream toolchain badges" + + for attempt in 1 2 3; do + if git push origin HEAD:main; then + echo "Pushed on attempt ${attempt}." + exit 0 + fi + echo "Push rejected on attempt ${attempt}; rebasing onto the new main." + git fetch origin main + git rebase origin/main + done + + echo "::error::could not push the badge commit after 3 attempts" + exit 1 diff --git a/.shields/sg.json b/.shields/sg.json new file mode 100644 index 00000000..24bd3e05 --- /dev/null +++ b/.shields/sg.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "label": "sg", + "message": "0.41.0 · 2026-08-22", + "color": "yellow" +} diff --git a/.shields/vale.json b/.shields/vale.json new file mode 100644 index 00000000..1a1812bb --- /dev/null +++ b/.shields/vale.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "label": "vale", + "message": "3.17.1 · 2026-08-22", + "color": "yellow" +} diff --git a/packages/cli/README.md b/packages/cli/README.md index b6c3fc2e..357e95e6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,5 +1,11 @@ # @taskless/cli +[![npm](https://img.shields.io/npm/v/@taskless/cli)](https://www.npmjs.com/package/@taskless/cli) +[![build](https://img.shields.io/github/actions/workflow/status/taskless/cli/validate.yml?branch=main)](https://github.com/taskless/cli/actions/workflows/validate.yml?query=branch%3Amain) +[![nightly](https://img.shields.io/badge/nightly-npm-blue)](https://www.npmjs.com/package/@taskless/cli-nightly) +[![vale](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/taskless/cli/main/.shields/vale.json)](https://github.com/errata-ai/vale/releases) +[![sg](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/taskless/cli/main/.shields/sg.json)](https://github.com/ast-grep/ast-grep/releases) + CLI companion for [Taskless](https://taskless.io). Designed to work with agent skills to add constraints that improve coding agent output. ## Install