Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8d34cb2
Add release queue workflow in dry run mode
ArmandPhilippot Oct 21, 2026
4506d93
Join accepted PRs to their release umbrella
ArmandPhilippot Oct 21, 2026
a5d53f3
Refresh release umbrellas after each join
ArmandPhilippot Oct 21, 2026
a63654b
Block umbrella merges until a maintainer checks them
ArmandPhilippot Oct 22, 2026
d076a44
Link prerelease umbrellas to their release PR
ArmandPhilippot Oct 22, 2026
cf6adcd
First pass to make the release queue code more readable
ArmandPhilippot Oct 22, 2026
4602e6f
Second pass to make the release queue code more readable
ArmandPhilippot Oct 23, 2026
2e7d8ea
Group the release queue modules into folders
ArmandPhilippot Oct 23, 2026
c0f26fd
more renaming and reorganization
ArmandPhilippot Oct 23, 2026
287978c
Another cleaning pass
ArmandPhilippot Oct 23, 2026
dc7402e
more rewording, renaming, and reorganization
ArmandPhilippot Oct 26, 2026
7553b95
Make local runs of the release queue print-only
ArmandPhilippot Oct 26, 2026
ff03c21
Rename the refresh flag to `--preview-refresh`
ArmandPhilippot Oct 26, 2026
54cc9f1
Turn the release queue join into a class
ArmandPhilippot Oct 26, 2026
1cb34ac
Move generic repository operations into the GitHub module
ArmandPhilippot Oct 26, 2026
b827a94
Refresh umbrellas and their merge-block review in one step
ArmandPhilippot Oct 26, 2026
104ec35
Move the release queue flows next to its entry point
ArmandPhilippot Oct 26, 2026
b1eba84
Define the docs PR and umbrella types with their concepts
ArmandPhilippot Oct 26, 2026
2218a40
Rename the accepted folder after the join it holds
ArmandPhilippot Oct 26, 2026
1088070
Make generic repository operations methods of Repository
ArmandPhilippot Oct 26, 2026
6a96d34
Handle an accepted PR through a class built once per run
ArmandPhilippot Oct 26, 2026
2457f74
Read optional GitHub resources without catching 404s by hand
ArmandPhilippot Oct 26, 2026
8bae926
Read the framework releases once per run
ArmandPhilippot Oct 26, 2026
8a7de9a
List open PRs through the repository, with GitHub's head format
ArmandPhilippot Oct 26, 2026
02fee92
Build the run context in its own module
ArmandPhilippot Oct 26, 2026
87783cb
Keep the merge-block reads with the review, and group the text formats
ArmandPhilippot Oct 26, 2026
8ed8d82
Write the code spans the bot posts in one place
ArmandPhilippot Oct 26, 2026
e32ddcf
Classify check runs and commit statuses as one kind of check
ArmandPhilippot Oct 26, 2026
da78982
Test that a pending commit status refuses a PR
ArmandPhilippot Oct 26, 2026
2455d2b
Read whether a docs PR is mergeable from the PR only
ArmandPhilippot Oct 26, 2026
d1a3ba9
Update the labels of a PR through the repository
ArmandPhilippot Oct 26, 2026
92165a9
Refresh umbrellas through a refresher built once per run
ArmandPhilippot Oct 26, 2026
88dc80b
Read the PR and its label sender together from the flags or the event
ArmandPhilippot Oct 26, 2026
d9f210d
Split the umbrella refresh into what it shows and what it writes
ArmandPhilippot Oct 26, 2026
a5d4954
Shorten the doc comments of the release queue
ArmandPhilippot Oct 26, 2026
b58cfe2
Merge branch 'main' into armand/release-queue
ArmandPhilippot Oct 26, 2026
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
58 changes: 58 additions & 0 deletions .github/workflows/release-queue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Routes docs PRs labeled as accepted to the matching umbrella PR.
# Can be run manually to refresh the open umbrella PRs (e.g. when a release PR
# changes from patch to minor in `withastro/astro`).
# `MODE` sets which PRs it acts on: see `scripts/lib/release-queue/modes.ts`.

name: Release queue

on:
# Needs a token that can comment on fork PRs. Never checks out the PR code.
# zizmor: ignore[dangerous-triggers]
pull_request_target:
types: [labeled]
workflow_dispatch:

# Runs wait in a queue and execute one at a time, as joins update the same
# umbrella PR. The docs only show `queue` at the workflow level: runs for
# other labels are queued too.
concurrency:
group: release-queue
queue: max

permissions: {}

jobs:
route:
name: Route to release umbrella
if: ${{ github.repository_owner == 'withastro' && (github.event_name == 'workflow_dispatch' || github.event.label.name == 'accepted') }}
runs-on: ubuntu-latest
# The job takes seconds. A hung run would block every run queued behind it.
timeout-minutes: 5
permissions:
checks: read # Reads the checks of the PR.
contents: read
pull-requests: write # Comments on PRs, and reviews umbrella PRs.
statuses: read # Reads the commit statuses of the PR.
steps:
# The script always comes from `main`, whatever the base branch of the PR.
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: false
sparse-checkout: scripts

# The script has no dependencies, only Node.js is required.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc

- name: Route the PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Used outside of dry run to write the umbrella PRs.
BOT_TOKEN: ${{ secrets.FREDKBOT_GITHUB_TOKEN }}
# `dry-run` mode only simulates the routing without making changes. Use `stable-only` or `all` for actual routing.
MODE: dry-run
run: node --experimental-transform-types ./scripts/release-queue.ts
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"lint:slugcheck": "node ./scripts/lint-slugcheck.mjs",
"lint:eslint": "eslint .",
"lint:ja": "textlint --cache --config .textlintrc.ja.json src/content/docs/ja/**/*.{md,mdx}",
"lunaria:build": "node --experimental-transform-types ./scripts/lunaria.mts"
"lunaria:build": "node --experimental-transform-types ./scripts/lunaria.mts",
"test:release-queue": "node --experimental-transform-types --test './scripts/lib/release-queue/**/*.test.ts'"
},
"devDependencies": {
"@actions/core": "^1.11.1",
Expand Down
92 changes: 92 additions & 0 deletions scripts/lib/release-queue/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { chooseAction, parseEvent } from './cli.ts';

describe('chooseAction', () => {
it('handles the PR of the event', () => {
const action = chooseAction({
eventName: 'pull_request_target',
eventPullRequest: 7,
eventSender: 'maintainer',
});
assert.deepEqual(action, { type: 'handle', prNumber: 7, labelSender: 'maintainer' });
});

it('handles the PR passed with --pr, labeled by --sender', () => {
assert.deepEqual(chooseAction({ pr: '14580', sender: 'maintainer' }), {
type: 'handle',
prNumber: 14580,
labelSender: 'maintainer',
});
});

it('handles a PR without a sender in a local run', () => {
assert.deepEqual(chooseAction({ pr: '14580' }), {
type: 'handle',
prNumber: 14580,
labelSender: undefined,
});
});

it('refuses a PR without a sender when the write access check must run', () => {
assert.throws(
() => chooseAction({ eventPullRequest: 7, isSenderRequired: true }),
/^Error: The event has no sender: the write access check needs one\.$/
);
});

it('refreshes on a workflow_dispatch, or with --preview-refresh', () => {
assert.deepEqual(chooseAction({ eventName: 'workflow_dispatch' }), { type: 'refresh' });
assert.deepEqual(chooseAction({ isRefreshRequested: true }), { type: 'refresh' });
});

it('refuses a missing PR number, instead of refreshing', () => {
assert.throws(() => chooseAction({}), /^Error: No PR to handle: pass --pr <number>\.$/);
});

it('refuses anything but the digits of a PR number', () => {
for (const pr of ['abc', '1O3', '0', '-3', '7.5', '1e3', '0x10', ' 7 ', '']) {
assert.throws(() => chooseAction({ pr }), /^Error: Invalid PR number: .*\. Pass --pr/, pr);
}
});

it('refuses --preview-refresh with --pr', () => {
assert.throws(() => chooseAction({ pr: '7', isRefreshRequested: true }), /not both/);
});
});

describe('parseEvent', () => {
const path = '/tmp/event.json';

it('reads the PR number and the sender of a labeled event', () => {
const event = {
action: 'labeled',
pull_request: { number: 7 },
sender: { login: 'maintainer' },
};
assert.deepEqual(parseEvent(JSON.stringify(event), path), {
pullRequest: 7,
labelSender: 'maintainer',
});
});

it('reads neither from a workflow_dispatch event', () => {
assert.deepEqual(parseEvent('{"inputs":{}}', path), {
pullRequest: undefined,
labelSender: undefined,
});
});

it('names the file and the field of an unexpected event', () => {
assert.throws(
() => parseEvent('not json', path),
/The event in \/tmp\/event\.json isn't valid JSON/
);
assert.throws(() => parseEvent('[]', path), /isn't an object/);
assert.throws(
() => parseEvent('{"pull_request":{"number":"7"}}', path),
/has no number in `pull_request`/
);
assert.throws(() => parseEvent('{"sender":{}}', path), /has no login in `sender`/);
});
});
99 changes: 99 additions & 0 deletions scripts/lib/release-queue/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { isRecord, parseJson } from './formats/json.ts';
import type { LabeledPullRequest } from './handle-accepted.ts';

/** A positive integer, written with digits only. */
const PR_NUMBER = /^[1-9]\d*$/;

type Action = ({ type: 'handle' } & LabeledPullRequest) | { type: 'refresh' };

/**
* Chooses what a run does. A `workflow_dispatch` event, or
* `--preview-refresh` in a local run, refreshes the open umbrellas. Otherwise,
* the run handles a PR. For the PR and its sender, a flag wins over the event.
*
* @param {object} signals - The flags and the event of the run.
* @param {string} [signals.pr] - The value of `--pr`.
* @param {string} [signals.sender] - The value of `--sender`.
* @param {boolean} [signals.isRefreshRequested] - Whether
* `--preview-refresh` was passed.
* @param {string} [signals.eventName] - The event that triggered the run.
* @param {number} [signals.eventPullRequest] - The PR of that event.
* @param {string} [signals.eventSender] - Who triggered that event.
* @param {boolean} [signals.isSenderRequired] - Whether the write access
* check must run, as in GitHub Actions.
* @returns {Action} The refresh, or the PR to handle with who labeled it.
* @throws {Error} When both a PR and a refresh are asked for, when the PR
* number is missing or invalid, or when a required sender is missing.
*/
export function chooseAction({
pr,
sender,
isRefreshRequested = false,
eventName,
eventPullRequest,
eventSender,
isSenderRequired = false,
}: {
pr?: string | undefined;
sender?: string | undefined;
isRefreshRequested?: boolean | undefined;
eventName?: string | undefined;
eventPullRequest?: number | undefined;
eventSender?: string | undefined;
isSenderRequired?: boolean | undefined;
}): Action {
const shouldRefresh = isRefreshRequested || eventName === 'workflow_dispatch';
if (shouldRefresh && pr !== undefined)
throw new Error('Pass --pr or --preview-refresh, not both.');
if (shouldRefresh) return { type: 'refresh' };

const rawPrNumber = pr ?? eventPullRequest?.toString();
if (rawPrNumber === undefined) throw new Error('No PR to handle: pass --pr <number>.');
// Digits only: `Number()` would also read `1e3`, `0x10` or ` 7 `.
if (!PR_NUMBER.test(rawPrNumber)) {
throw new Error(`Invalid PR number: ${rawPrNumber}. Pass --pr <number>.`);
}

const labelSender = sender ?? eventSender;
if (labelSender === undefined && isSenderRequired) {
throw new Error('The event has no sender: the write access check needs one.');
}
return { type: 'handle', prNumber: Number(rawPrNumber), labelSender };
}

/** What a run reads from the event of GitHub Actions. */
export interface RunEvent {
pullRequest?: number | undefined;
labelSender?: string | undefined;
}

/**
* Reads the PR number and the login of who added the label from an event.
*
* @param {string} json - The content of the event file.
* @param {string} path - The path of the event file, for the errors.
* @returns {RunEvent} What the event holds.
* @throws {Error} When the event isn't a JSON object, or a field is malformed.
*/
export function parseEvent(json: string, path: string): RunEvent {
const event = parseJson(json, `The event in ${path}`);
if (!isRecord(event)) throw new Error(`The event in ${path} isn't an object.`);
return {
pullRequest: readPullRequestNumber(event.pull_request, path),
labelSender: readSenderLogin(event.sender, path),
};
}

function readPullRequestNumber(pullRequest: unknown, path: string): number | undefined {
if (pullRequest === undefined) return undefined;
if (isRecord(pullRequest) && Number.isInteger(pullRequest.number)) {
return Number(pullRequest.number);
}
throw new Error(`The event in ${path} has no number in \`pull_request\`.`);
}

function readSenderLogin(sender: unknown, path: string): string | undefined {
if (sender === undefined) return undefined;
if (isRecord(sender) && typeof sender.login === 'string') return sender.login;
throw new Error(`The event in ${path} has no login in \`sender\`.`);
}
47 changes: 47 additions & 0 deletions scripts/lib/release-queue/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// The names the release queue uses: repositories, branches, labels, and the
// accounts and directives it relies on.

// The docs repository.

export const DOCS_REPO = 'withastro/docs';
export const DOCS_STABLE_BRANCH = 'main';
export const UMBRELLA_BRANCH_PREFIX = 'release-queue/';

/** The label meaning a docs PR ships with the next release. */
export const ACCEPTED_LABEL = 'accepted';
export const MERGE_ON_RELEASE_LABEL = 'merge-on-release';
/** Added by `pr-labeler.yml` when translations change. */
export const TRANSLATIONS_LABEL = 'i18n';

/** The login of the bot behind `GITHUB_TOKEN`. */
export const MERGE_BLOCK_REVIEWER_LOGIN = 'github-actions[bot]';

export const LUNARIA_DIRECTIVE = '@lunaria-track:src/content/docs/en/**.mdx';
export const LUNARIA_DIRECTIVE_DOCS =
'https://lunaria.dev/guides/tracking/#triggering-changes-per-file';

// The framework repository.

export const FRAMEWORK_REPO = 'withastro/astro';
export const FRAMEWORK_CORE_PACKAGE = 'astro';
export const FRAMEWORK_STABLE_BRANCH = 'main';
export const FRAMEWORK_PRERELEASE_BRANCH = 'next';
export const RELEASE_TITLE_PREFIX = '[ci] release';

// The release types, and the docs labels that name them.

export const RELEASE_TYPES = { patch: 'patch', minor: 'minor', major: 'major' } as const;

export type ReleaseType = (typeof RELEASE_TYPES)[keyof typeof RELEASE_TYPES];

export const RELEASE_LABELS = {
patch: `${RELEASE_TYPES.patch}-release`,
minor: `${RELEASE_TYPES.minor}-release`,
major: `${RELEASE_TYPES.major}-release`,
} as const satisfies { [Type in ReleaseType]: `${Type}-release` };

export type ReleaseLabel = (typeof RELEASE_LABELS)[keyof typeof RELEASE_LABELS];

export const ALL_RELEASE_LABELS: ReleaseLabel[] = Object.values(RELEASE_LABELS);

export const FALLBACK_RELEASE_LABEL: ReleaseLabel = RELEASE_LABELS.patch;
27 changes: 27 additions & 0 deletions scripts/lib/release-queue/formats/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Whether a value is an object whose fields can be read, and not `null` or an
* array.
*
* @param {unknown} value - The value to check, such as parsed JSON.
* @returns {boolean} `true` for an object.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Parses a JSON text.
*
* @param {string} json - The text to parse.
* @param {string} source - What the text is, to name it in the error.
* @returns {unknown} The parsed value, to validate before use.
* @throws {Error} When the text isn't valid JSON, with the parse error as its
* cause.
*/
export function parseJson(json: string, source: string): unknown {
try {
return JSON.parse(json);
} catch (error) {
throw new Error(`${source} isn't valid JSON.`, { cause: error });
}
}
22 changes: 22 additions & 0 deletions scripts/lib/release-queue/formats/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The Markdown text of PR descriptions, comments and reviews.

/**
* Replaces with `\n` the `\r\n` line endings that GitHub keeps when a
* description or a review is edited in the browser.
*
* @param {string} text - A description or a review body read from GitHub.
* @returns {string} The text with `\n` line endings only.
*/
export function normalizeLineEndings(text: string): string {
return text.replaceAll('\r\n', '\n');
}

/**
* Writes text as inline code.
*
* @param {string} text - A name, such as a branch or a label.
* @returns {string} The text between backticks.
*/
export function wrapInCode(text: string): string {
return `\`${text}\``;
}
Loading
Loading