Skip to content
Merged
79 changes: 79 additions & 0 deletions .github/scripts/pin-bump.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
"use strict";

/**
* Move a family of exact dependency pins from one version to another, in the
* package.json SOURCE TEXT rather than in a parsed object.
*
* Shared by the two upgrade detectors — ast-grep's and Vale's — because the
* rewrite is the same operation on a different prefix, and because the count
* check below is the kind of guard that is worth having exactly one of.
*
* WHY TEXT AND NOT JSON. Parsing and re-serializing would reformat a file this
* repository formats with prettier, and it would do so in CI, where no
* `lint-staged` runs to normalize it back. The bump would then arrive as a
* whole-file diff with six version strings buried in it. A targeted replacement
* leaves every byte it did not have to touch, so the review is the versions.
*
* WHY THE COUNT IS RETURNED RATHER THAN ASSUMED. A pin the pattern fails to
* match is the failure that matters: it leaves a straggler at the old version,
* and because these platform packages are selected by optional dependency, a
* straggler is a DIFFERENT BINARY on one platform than on the others. Callers
* compare this count against the pins they independently enumerated and abort
* on a mismatch, so the two disagreeing is a failed run rather than a
* half-applied upgrade that looks fine in review.
*/

/** Escape a version for literal use in a regular expression. */
function escapeLiteral(text) {
return String(text).replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}

/**
* WHY THE CALLER PASSES A PATTERN RATHER THAN A PREFIX.
*
* This used to take a bare string prefix and match any key starting with it,
* while the `collectPins` it is paired with used a boundary-aware pattern
* (`/^@ast-grep\/cli(-|$)/`). Two functions that are supposed to agree on what
* counts as a pin disagreed on it, and only the `count !== pins.size` check
* downstream kept that from mattering. Relying on a guard to paper over a
* disagreement is not the same as not having one: the guard turns the
* disagreement into a failed run, which is better than a wrong bump and worse
* than the two agreeing in the first place.
*
* Taking the pattern means the caller hands BOTH functions the same constant,
* so they cannot drift apart at all.
*
* @param source the package.json text
* @param pattern anchored RegExp matching a package NAME, the same one the
* caller enumerates pins with
* @param from the exact version every matching pin currently holds
* @param to the exact version to write
*/
function bumpPins(source, { pattern, from, to }) {
// A /g regexp carries `lastIndex` between calls, so `.test()` would alternate
// true and false down the file and silently skip every other pin. Refusing it
// beats stripping the flag, because a caller passing /g also uses that same
// constant for its own enumeration, where the bug would be just as quiet.
if (pattern.global) {
throw new Error(
"the pin pattern must not be /g: a stateful lastIndex would skip pins"
);
}
const matcher = new RegExp(
`("([^"]+)"\\s*:\\s*")${escapeLiteral(from)}(")`,
"g"
);
let count = 0;
const bumped = source.replaceAll(matcher, (match, head, name, tail) => {
if (!pattern.test(name)) {
return match;
}
count += 1;
return `${head}${to}${tail}`;
});
return { source: bumped, count };
}

module.exports = { bumpPins, escapeLiteral };
116 changes: 116 additions & 0 deletions .github/scripts/pin-bump.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: MIT
"use strict";

/**
* Tests for pin-bump.cjs.
*
* Two properties matter here and neither is about the happy path. The rewrite
* must not touch a package it was not asked about, and it must agree with the
* caller's own enumeration about what counts as a pin — a disagreement there
* leaves a straggler at the old version, which for optional-dependency platform
* packages means a different binary on one platform than on the others.
*/

const test = require("node:test");
const assert = require("node:assert/strict");

const { bumpPins } = require("./pin-bump.cjs");

/** The boundary-aware pattern sg-detect.cjs enumerates its pins with. */
const AST_GREP = /^@ast-grep\/cli(-|$)/;

const sourcePinnedAt = (version) =>
`${JSON.stringify(
{
name: "@taskless/cli",
dependencies: { "@ast-grep/cli": version, zod: "^4.0.0" },
optionalDependencies: {
"@ast-grep/cli-darwin-arm64": version,
"@ast-grep/cli-linux-x64-gnu": version,
},
},
undefined,
2
)}\n`;

test("every pin moves and nothing else does", () => {
const before = sourcePinnedAt("0.45.2");
const { source, count } = bumpPins(before, {
pattern: AST_GREP,
from: "0.45.2",
to: "0.45.3",
});

assert.equal(count, 3);
assert.equal(source.match(/0\.45\.3/g).length, 3);
assert.doesNotMatch(source, /0\.45\.2/);
assert.match(source, /"zod": "\^4\.0\.0"/);
// The formatting is untouched, so the diff a reviewer reads is the versions.
assert.equal(before.split("\n").length, source.split("\n").length);
});

/**
* The dots in a version are regular-expression metacharacters. Unescaped,
* `0.45.2` also matches `0145.2`.
*/
test("the version is matched literally, not as a pattern", () => {
const { count } = bumpPins('{ "@ast-grep/cli": "0145.2" }', {
pattern: AST_GREP,
from: "0.45.2",
to: "0.45.3",
});
assert.equal(count, 0);
});

test("the same version under an unrelated package is left alone", () => {
const { source, count } = bumpPins(
'{ "some-other-tool": "0.45.2", "@ast-grep/cli": "0.45.2" }',
{ pattern: AST_GREP, from: "0.45.2", to: "0.45.3" }
);
assert.equal(count, 1);
assert.match(source, /"some-other-tool": "0\.45\.2"/);
});

/**
* The reason the caller passes a pattern rather than a prefix. A bare prefix
* match treats `@ast-grep/clippy` as a pin because the string starts the same
* way, while the `collectPins` it is paired with — anchored on `(-|$)` — does
* not. The two would then disagree about what a pin is, and only the caller's
* count check would notice.
*/
test("a package that merely starts with the prefix is not a pin", () => {
const { source, count } = bumpPins(
'{ "@ast-grep/clippy": "0.45.2", "@ast-grep/cli-darwin-arm64": "0.45.2" }',
{ pattern: AST_GREP, from: "0.45.2", to: "0.45.3" }
);
assert.equal(count, 1);
assert.match(source, /"@ast-grep\/clippy": "0\.45\.2"/);
assert.match(source, /"@ast-grep\/cli-darwin-arm64": "0\.45\.3"/);
});

test("the Vale prefix carries its boundary in the pattern", () => {
const pattern = /^@taskless\/vale-/;
const { count } = bumpPins(
'{ "@taskless/vale-linux-x64": "3.20.0-20260907164938", "@taskless/valet": "3.20.0-20260907164938" }',
{ pattern, from: "3.20.0-20260907164938", to: "3.21.0-20260914010203" }
);
assert.equal(count, 1);
});

/**
* A /g regexp carries `lastIndex` between calls, so `.test()` alternates true
* and false down the file and silently skips every other pin. Refusing it beats
* stripping the flag: the caller uses that same constant for its own
* enumeration, where the bug would be just as quiet.
*/
test("a stateful /g pattern is refused rather than silently skipping pins", () => {
assert.throws(
() =>
bumpPins(sourcePinnedAt("0.45.2"), {
pattern: /^@ast-grep\/cli(-|$)/g,
from: "0.45.2",
to: "0.45.3",
}),
/must not be \/g/
);
});
187 changes: 187 additions & 0 deletions .github/scripts/release-notes.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
"use strict";

/**
* Upstream release notes, fetched and rendered for a pull request or issue body.
*
* WHY THIS EXISTS. Both detect scripts answer "is upstream ahead?" with a
* version number, and a version number is not enough to review a bump. A
* reviewer looking at `3.20.0 -> 3.21.0` and six changed digests has no way to
* tell a security fix from a docs release without leaving the pull request and
* going to find the changelog by hand. Carrying the notes into the body is the
* difference between reviewing a bump and approving one.
*
* THE NOTES ARE UNTRUSTED TEXT. They are Markdown written by a third party and
* they reach a body that a workflow composes. Nothing here interpolates them
* into a shell command, a `${{ }}` expression, or a $GITHUB_OUTPUT line — a
* release body is free to contain a heredoc terminator or an output delimiter,
* and either one is an injection if it meets a shell. `writeNotesFile` puts
* them on disk and the workflow passes that path to `--body-file`, so the bytes
* never pass through an interpreter.
*
* They are also FENCED when rendered. An upstream body that opens a fence and
* never closes it, or that ends mid-table, would otherwise swallow whatever the
* workflow appends after it. Quoting the whole block as Markdown blockquote
* lines keeps the surrounding body's structure independent of what upstream
* wrote, at the cost of one level of indentation in the rendering.
*/

const { writeFileSync } = require("node:fs");

/**
* How much of an upstream body to carry, measured AFTER quoting.
*
* A GitHub pull request or issue body caps at 65536 characters, and a body that
* hits the cap fails the API call rather than truncating — so the whole
* proposal is lost to a talkative release. The limit is well under the cap
* because the workflow's own preamble and the truncation footer have to fit
* alongside it.
*
* Measured after quoting because the two differ by more than a rounding error:
* every line gains two characters of blockquote marker, so a release whose
* notes are a long list of short lines — which is exactly what ast-grep's
* generated changelog is — nearly doubles. Budgeting against the source length
* let a 40000-character body render as 80202, over the cap the limit exists to
* stay under.
*/
const NOTES_LIMIT = 40_000;

/**
* Fetch a release from the GitHub API.
*
* `reference` is either `latest` or `tags/<tag>`, matching the two endpoint
* shapes. GITHUB_TOKEN, when present, is only for the rate limit; both
* endpoints are public.
*
* A missing release is NOT an error. Upstream may tag without publishing a
* release, or name its tags differently from its npm versions, and neither is a
* reason to fail a detect run whose actual job — comparing versions — already
* succeeded. The caller gets `undefined` and says so in the body.
*/
async function fetchRelease(repository, reference) {
const headers = {
accept: "application/vnd.github+json",
"user-agent": "taskless-release-notes",
};
if (process.env.GITHUB_TOKEN) {
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const url = `https://api.github.com/repos/${repository}/releases/${reference}`;
const response = await fetch(url, { headers });
if (response.status === 404) {
return undefined;
}
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 {
tag: release.tag_name,
notes: typeof release.body === "string" ? release.body : "",
url: typeof release.html_url === "string" ? release.html_url : undefined,
};
}

/** The latest non-prerelease, non-draft release. */
const fetchLatestRelease = (repository) => fetchRelease(repository, "latest");

/** A specific release by tag, for upstreams whose version we learn elsewhere. */
const fetchReleaseByTag = (repository, tag) =>
fetchRelease(repository, `tags/${encodeURIComponent(tag)}`);

/**
* Render a release as a Markdown section, quoted so it cannot restructure the
* body around it.
*
* `release` may be undefined (no release found) and its `notes` may be empty (a
* release published with no body). Both render as a line saying so plus a link,
* rather than as an absent section — "upstream wrote no notes" and "we forgot
* to fetch them" look identical otherwise, and only one of them is fine.
*/
function formatReleaseNotes({
repository,
version,
release,
limit = NOTES_LIMIT,
}) {
const link = release?.url ?? `https://github.com/${repository}/releases`;
const heading = `## Upstream release notes — ${version}\n\n${link}\n`;

const body = release?.notes?.trim() ?? "";
if (body.length === 0) {
return `${heading}\nUpstream published no release notes for this version.\n`;
}

// Truncation happens on a line boundary, so the rendering cannot end
// mid-marker and produce a line that is not part of the quoted block.
const lines = body
.split("\n")
.map((line) => (line.length > 0 ? `> ${line}` : ">"));
const kept = [];
let length = 0;
for (const line of lines) {
if (length + line.length + 1 > limit) {
break;
}
kept.push(line);
length += line.length + 1;
}
let truncated = kept.length < lines.length;
// A first line longer than the whole budget keeps nothing, which would render
// a truncation footer under an empty quote. Cut inside that line instead: a
// release whose notes are one enormous paragraph still says something. This
// is also truncation, even when it drops no whole line.
if (kept.length === 0) {
kept.push(lines[0].slice(0, limit));
truncated = true;
}
const quoted = kept.join("\n");

const footer = truncated
? `\n\n_Truncated at ${limit} characters. Read the rest at ${link}._\n`
: "\n";
return `${heading}\n${quoted}\n${footer}`;
}

/**
* Read `--notes-out <path>` from an argv array, or undefined when absent.
*
* Lives here rather than in each detect script because all three parse the same
* flag for the same reason, and a change to how it is parsed — accepting
* `--notes-out=path`, say — should not be a change three files have to make in
* agreement. A partial fix would leave one workflow silently writing nothing.
*
* A following value that looks like another flag is an error rather than a
* path. `--notes-out --write` is a caller that forgot the argument, and taking
* `--write` as a filename would write release notes to a file named `--write`
* and drop the flag that was meant to do the work.
*/
function readNotesOut(argv) {
const at = argv.indexOf("--notes-out");
if (at === -1) {
return undefined;
}
const path = argv[at + 1];
if (!path || path.startsWith("--")) {
throw new Error("--notes-out needs a path");
}
return path;
}

/** Write a rendered section to disk for a workflow to pass to `--body-file`. */
function writeNotesFile(path, section) {
writeFileSync(path, section.endsWith("\n") ? section : `${section}\n`);
}

module.exports = {
NOTES_LIMIT,
fetchLatestRelease,
fetchReleaseByTag,
formatReleaseNotes,
readNotesOut,
writeNotesFile,
};
Loading
Loading