From 7caee53fda580bf0ef65e83c884db916192f5737 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 28 Aug 2026 18:04:21 +0200 Subject: [PATCH 1/4] Add version regression guard for resolved releases --- .../release-info/test-version-guard.js | 72 +++++++++++++++++++ .../actions/release-info/version-guard.js | 57 +++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 .github/workflows/actions/release-info/test-version-guard.js create mode 100644 .github/workflows/actions/release-info/version-guard.js diff --git a/.github/workflows/actions/release-info/test-version-guard.js b/.github/workflows/actions/release-info/test-version-guard.js new file mode 100644 index 0000000000..e14bec2f18 --- /dev/null +++ b/.github/workflows/actions/release-info/test-version-guard.js @@ -0,0 +1,72 @@ +const assert = require("assert"); +const { assertNotOlder } = require("./version-guard"); + +// 1. The four values the API served on 2026-08-17, against the version +// committed at the time. Every one must be rejected. +for (const bad of ["1.2.113", "0.2.434", "1.9.10", "1.5.49"]) { + assert.throws( + () => assertNotOlder(bad, "1.11.1", "Latest prerelease"), + /older than the committed version/, + `rejects stale ${bad}` + ); +} + +// 2. Ordering is numeric, not lexicographic. Both of these invert under +// string comparison, which is the trap this module exists to avoid. +assert.throws( + () => assertNotOlder("1.9.10", "1.11.1", "Latest prerelease"), + /older than the committed version/, + "minor 9 is older than 11" +); +assert.throws( + () => assertNotOlder("1.10.2", "1.10.10", "Latest prerelease"), + /older than the committed version/, + "patch 2 is older than 10" +); + +// 3. Equal and newer are accepted. Equal is the common case: the cron job +// runs every 15 minutes and usually resolves the same version. +assert.doesNotThrow( + () => assertNotOlder("1.11.1", "1.11.1", "Latest prerelease"), + "equal accepted" +); +assert.doesNotThrow( + () => assertNotOlder("1.12.0", "1.11.1", "Latest prerelease"), + "newer accepted" +); + +// 4. Callers pass a raw tag_name, so a leading v must compare correctly. +assert.doesNotThrow( + () => assertNotOlder("v1.11.2", "1.11.1", "Latest prerelease"), + "leading v on candidate" +); +assert.throws( + () => assertNotOlder("v1.9.10", "1.11.1", "Latest prerelease"), + /older than the committed version/, + "leading v on a stale candidate" +); + +// 5. Unusable input fails loudly rather than silently skipping the check. +assert.throws( + () => assertNotOlder("1.12.0-rc1", "1.11.1", "Latest prerelease"), + /resolved version "1\.12\.0-rc1" is not a valid X\.Y\.Z version/, + "unparseable candidate" +); +assert.throws( + () => assertNotOlder("1.12.0", undefined, "Latest prerelease"), + /committed version "undefined" is not a valid X\.Y\.Z version/, + "missing baseline" +); + +// 6. The rejection message names both versions and the channel, so the failed +// run is diagnosable from the log alone. +assert.throws( + () => assertNotOlder("v1.9.10", "1.11.1", "Latest prerelease"), + (error) => + error.message.includes("Latest prerelease") && + error.message.includes("1.9.10") && + error.message.includes("1.11.1"), + "message identifies channel and both versions" +); + +console.log("All version-guard tests passed"); diff --git a/.github/workflows/actions/release-info/version-guard.js b/.github/workflows/actions/release-info/version-guard.js new file mode 100644 index 0000000000..e97ac00365 --- /dev/null +++ b/.github/workflows/actions/release-info/version-guard.js @@ -0,0 +1,57 @@ +// Reject a release version that is older than the one already committed. +// +// The GitHub Releases API has served inconsistent paginated results, handing +// back a valid-looking but months-old release in response to a request for the +// newest page. Such a payload is indistinguishable from a good one by +// inspection: the release exists, is flagged prerelease, and still has assets. +// The version already committed to this repo is the only trustworthy reference +// point, so a resolved version may never move backwards. + +// Parse "1.11.1" or "v1.11.1" into [1, 11, 1]. Returns undefined for anything +// that is not exactly three numeric segments. +function parseVersion(version) { + if (typeof version !== "string") { + return undefined; + } + const parts = version.trim().replace(/^v/, "").split("."); + if (parts.length !== 3) { + return undefined; + } + const numbers = parts.map((part) => + /^\d+$/.test(part) ? Number(part) : NaN + ); + return numbers.some(Number.isNaN) ? undefined : numbers; +} + +function assertNotOlder(candidate, previous, label) { + const committed = parseVersion(previous); + if (!committed) { + throw new Error( + `${label}: committed version "${previous}" is not a valid X.Y.Z version.` + ); + } + const resolved = parseVersion(candidate); + if (!resolved) { + throw new Error( + `${label}: resolved version "${candidate}" is not a valid X.Y.Z version.` + ); + } + + // Compare segment by segment. String comparison is wrong here - "1.10.2" + // sorts above "1.10.10", and "1.9.10" sorts above "1.11.1". + for (let i = 0; i < resolved.length; i++) { + if (resolved[i] !== committed[i]) { + if (resolved[i] < committed[i]) { + throw new Error( + `${label}: resolved version ${candidate} is older than the ` + + `committed version ${previous}. Refusing to regenerate download ` + + `data from a stale release.` + ); + } + break; + } + } + console.log(`${label}: accepted ${candidate} (committed: ${previous})`); +} + +module.exports = { assertNotOlder }; From c290df2e8c74db555198b14e33909fbc3eefdf2c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 28 Aug 2026 18:06:14 +0200 Subject: [PATCH 2/4] Reject stale releases before regenerating download data --- .../workflows/actions/release-info/index.js | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/.github/workflows/actions/release-info/index.js b/.github/workflows/actions/release-info/index.js index dcb39ee1fc..c156fec343 100644 --- a/.github/workflows/actions/release-info/index.js +++ b/.github/workflows/actions/release-info/index.js @@ -5,6 +5,7 @@ const hasha = require("hasha"); const fs = require("fs"); const path = require("path"); const { mergeRedirects } = require("./merge-redirects"); +const { assertNotOlder } = require("./version-guard"); async function run() { // Repo information @@ -26,7 +27,14 @@ async function run() { const redirectTemplate = core.getInput("redirects-template"); const preRedirectTemplate = core.getInput("pre-redirects-template") - // Function to process a release into a set of + // The version currently committed to a generated JSON file. This is the + // baseline a newly resolved release is checked against. A missing or + // unparseable file throws, which is correct: both files are committed in + // this repo, so their absence means something is already wrong. + const committedVersion = (filePath) => + JSON.parse(fs.readFileSync(filePath, "utf8")).version; + + // Function to process a release into a set of // download urls / info and a list of redirects const processRelease = async (releaseRaw) => { const releaseInfo = {}; @@ -89,34 +97,36 @@ async function run() { } } - // Function to get latest prerelease + // Function to get latest prerelease. + // + // Paging is capped. Stable releases are interleaved singly among + // prereleases, so the newest prerelease is always near the top of the list. + // Without a cap, a repository with no prerelease at all - or an API + // returning empty pages - loops until the job times out. + const maxPrereleasePages = 4; const getPrerelease = async () => { - // List the releases - var pagenumber = 1; - var matchedRelease = undefined; - while(true) { + for (let pagenumber = 1; pagenumber <= maxPrereleasePages; pagenumber++) { console.log("page " + pagenumber + " of prereleases"); - var releases = await octokit.rest.repos.listReleases({ + const releases = await octokit.rest.repos.listReleases({ owner, repo, per_page: 25, page: pagenumber - }); + }); + + if (releases.data.length === 0) { + break; + } for (const release of releases.data) { if (release.prerelease) { - matchedRelease = release; - break; + return release; } } - - if (matchedRelease) { - break; - } else { - pagenumber = pagenumber + 1; - } } - return matchedRelease; + throw new Error( + `No prerelease found in the first ${maxPrereleasePages} pages of releases for ${owner}/${repo}.` + ); } const generateRedirects = (redirects, redirTemplate) => { @@ -149,6 +159,11 @@ async function run() { owner, repo, }); + assertNotOlder( + latestRelease.data.tag_name, + committedVersion(pathToWrite), + "Latest release" + ); const releaseProcessed = await processRelease(latestRelease.data); const redirects = releaseProcessed.redirects; const releaseInfo = releaseProcessed.releaseInfo; @@ -160,6 +175,11 @@ async function run() { // Process the latest pre-release console.log("Starting prelease"); const prerelease = await getPrerelease(); + assertNotOlder( + prerelease.tag_name, + committedVersion(prePathToWrite), + "Latest prerelease" + ); const prereleaseProcessed = await processRelease(prerelease); const prereleaseInfo = prereleaseProcessed.releaseInfo; if (prereleaseInfo.assets === undefined || prereleaseInfo.assets.length === 0) { From 911ede714850fb3f6330d9c479a83e5672ada6c5 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 28 Aug 2026 18:07:42 +0200 Subject: [PATCH 3/4] Run release-info action tests in CI and document the version guard --- .github/workflows/README.md | 2 ++ .../actions/release-info/package.json | 2 +- .github/workflows/test-release-info.yml | 36 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-release-info.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1f7fcdeedf..7bca0bb96b 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -41,6 +41,8 @@ Note that technically, is also a deploy preview on Netli Local composite actions live in `.github/workflows/actions/`: - `release-info/` - Retrieves release information from the quarto-cli repository. + - **Version guard:** the resolved stable and prerelease versions are each compared against the version already committed in the JSON file they would overwrite, and a version older than the committed one fails the step. The GitHub Releases API has returned valid-looking but months-old releases in response to a request for the newest page, and such a payload cannot be told apart from good data, so the committed version is the only trustworthy reference point. A failure surfaces as a red run of the every-15-minutes `Update Downloads` schedule. + - Unit tests live alongside the action and run via `npm test` from `.github/workflows/actions/release-info/`; `test-release-info.yml` runs them on every PR touching it. ## Netlify Configurations diff --git a/.github/workflows/actions/release-info/package.json b/.github/workflows/actions/release-info/package.json index e662e7a3a8..176856958e 100644 --- a/.github/workflows/actions/release-info/package.json +++ b/.github/workflows/actions/release-info/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "node test-merge-redirects.js" + "test": "node test-merge-redirects.js && node test-version-guard.js" }, "keywords": [], "author": "", diff --git a/.github/workflows/test-release-info.yml b/.github/workflows/test-release-info.yml new file mode 100644 index 0000000000..9ae2604d1c --- /dev/null +++ b/.github/workflows/test-release-info.yml @@ -0,0 +1,36 @@ +# Runs the unit tests for the local release-info action. +name: Test release-info action + +on: + pull_request: + paths: ['.github/workflows/actions/release-info/**'] + push: + branches: [main, prerelease] + paths: ['.github/workflows/actions/release-info/**'] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Matches the node24 runtime declared in the action's action.yml. + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Run action tests + working-directory: .github/workflows/actions/release-info + run: npm test From 76ed879d99907a104bdc31c3cc4235a2a97f303c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 28 Aug 2026 18:23:28 +0200 Subject: [PATCH 4/4] Drop setup-node from the release-info test workflow zizmor flags actions/setup-node as a high-severity cache-poisoning risk because it enables caching by default, and audit-workflows.yml runs zizmor over .github on every PR. The repo audits clean without this file, so the step would have introduced the only finding in the tree. The pin bought very little: these tests use nothing beyond node:assert and other built-in modules, so the runner image Node is sufficient. --- .github/workflows/test-release-info.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-release-info.yml b/.github/workflows/test-release-info.yml index 9ae2604d1c..5784c370e5 100644 --- a/.github/workflows/test-release-info.yml +++ b/.github/workflows/test-release-info.yml @@ -25,12 +25,8 @@ jobs: with: persist-credentials: false - # Matches the node24 runtime declared in the action's action.yml. - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - + # No setup-node step: the tests use only built-in modules, and the + # runner image ships a Node new enough to run them. - name: Run action tests working-directory: .github/workflows/actions/release-info run: npm test