Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Note that technically, <prerelease.quarto.org> 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

Expand Down
54 changes: 37 additions & 17 deletions .github/workflows/actions/release-info/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {};
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/actions/release-info/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
72 changes: 72 additions & 0 deletions .github/workflows/actions/release-info/test-version-guard.js
Original file line number Diff line number Diff line change
@@ -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");
57 changes: 57 additions & 0 deletions .github/workflows/actions/release-info/version-guard.js
Original file line number Diff line number Diff line change
@@ -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 };
32 changes: 32 additions & 0 deletions .github/workflows/test-release-info.yml
Original file line number Diff line number Diff line change
@@ -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