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/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) { 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/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 }; diff --git a/.github/workflows/test-release-info.yml b/.github/workflows/test-release-info.yml new file mode 100644 index 0000000000..5784c370e5 --- /dev/null +++ b/.github/workflows/test-release-info.yml @@ -0,0 +1,32 @@ +# 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 + + # 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