Skip to content
Merged
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
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ permissions:
contents: read

# A newer run of a pull request's or a branch's checks cancels the one
# still going. Runs on main are never canceled: release.yml releases, and
# still going, and so does a merge queue run of a pull request that the
# queue built again, as it does when main moves under it. Each build of
# an entry has a ref of its own (gh-readonly-queue/main/pr-<N>-<base>,
# the base being the commit of main it was built on), so its group is
# keyed on its merge commit's message instead ("Merge pull request #<N>
# from ..."), which every build of one entry keeps, and which no other
# entry has. Runs on main are never canceled: release.yml releases, and
# deploy.yml deploys a release commit, only after a push run on main that
# passed, so a push run canceled by a later run, a manual one included,
# would leave its commit unreleased, or its version undeployed. Each event
Expand All @@ -58,7 +64,9 @@ permissions:
# may replace it.
concurrency:
group: >-
ci-${{ github.event_name }}-${{ github.ref }}${{
ci-${{ github.event_name }}-${{
github.event_name == 'merge_group' && github.event.merge_group.head_commit.message ||
github.ref }}${{
startsWith(github.event.head_commit.message, 'chore(release): ') &&
format('-{0}', github.sha) || '' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Expand Down
71 changes: 68 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ name: Release
# breaking change), commits the new version to main, tags it and makes a
# GitHub release of it. Run by hand from main, it releases main's tip
# once CI has passed on it, as after a release that failed. A fork's
# branch may be named main too, so the job checks, as deploy.yml does,
# branch may be named main too, so the jobs check, as deploy.yml does,
# that CI ran for a push, in this repository.
#
# While the merge queue for main holds entries, a push run releases
# nothing: a release commit moves main under the queue, and GitHub builds
# every entry again on top of it. The push run of the last entry to
# merge finds the queue empty and releases every commit since the last
# tag as one version. When that entry fails or leaves the queue, no push
# follows it, and main's commits wait for the next merge or a run by
# hand, which releases main's tip whatever the queue holds
# (docs/releasing.md#what-a-release-does).
#
# The release commit is pushed with a deploy key (the SEMANTIC_RELEASE_KEY
# secret), which the ruleset on main lets bypass its pull request: the
# run's own token could not, and a push made with it would start no
Expand All @@ -36,7 +45,7 @@ name: Release
# (docs/releasing.md#when-a-release-fails).

on:
workflow_run: # zizmor: ignore[dangerous-triggers] only the release job runs on it, for CI's push runs on main in this repository
workflow_run: # zizmor: ignore[dangerous-triggers] only the queue and release jobs run on it, for CI's push runs on main in this repository
workflows: [CI]
types: [completed]
branches: [main]
Expand All @@ -56,16 +65,72 @@ permissions:
actions: read

jobs:
# How many entries the merge queue for main holds, for the release job,
# which a push run releases with only when it holds none. It runs for
# the push runs the release job runs for, and holds no secret, no
# environment and a token that can only read: it checks out nothing
# and runs none of the repository's code. It asks GitHub's GraphQL API
# with the job's token, which reads a public repository's merge queue
# with contents: read. A query that fails fails the job, and so
# the run, rather than release or hold in silence: the next merge's
# run asks again, and a run by hand releases. A branch with no merge
# queue, as a fork's main may be, holds none.
queue:
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release): ')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
entries: ${{ steps.queue.outputs.entries }}
steps:
- name: The merge queue's entries
id: queue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
QUERY: >-
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
mergeQueue(branch: "main") { entries { totalCount } }
}
}
run: |
set -eo pipefail
entries="$(gh api graphql -f query="$QUERY" \
-f owner="${GH_REPO%%/*}" -f name="${GH_REPO#*/}" \
--jq '.data.repository.mergeQueue.entries.totalCount // 0')"
if ! [[ "$entries" =~ ^[0-9]+$ ]]; then
echo "::error::GitHub's API gave no count of the merge queue's entries: $entries"
exit 1
fi
if [ "$entries" != 0 ]; then
echo "::notice::The merge queue for main holds $entries entries, so nothing is released: the push run of the last to merge releases this commit with theirs (docs/releasing.md#what-a-release-does)."
fi
echo "entries=$entries" >> "$GITHUB_OUTPUT"

release:
# After the queue job, which a run by hand skips: !cancelled() lets
# the release job run when a job it needs was skipped, and a push run
# releases only once that job has found the queue empty.
needs: queue
if: >-
!cancelled() && (
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' &&
inputs.tag == '') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release): '))
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release): ') &&
needs.queue.result == 'success' && needs.queue.outputs.entries == '0'))
runs-on: ubuntu-latest
# Room for Ubuntu's mirror to stall (actions/playwright-deps gives each
# apt-get three minutes and one more try) before semantic-release
Expand Down
27 changes: 24 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
How Solid Memo's versions are chosen, what a version promises, and what
a release does. [semantic-release](https://semantic-release.gitbook.io/)
releases each version; nobody releases one by hand. After CI passes on a
push to `main`, it reads the commits since the last version and releases
the next one when any of them is releasable. Only a version is deployed
push to `main`, and once the merge queue is empty, it reads the commits
since the last version and releases the next one when any of them is
releasable. Only a version is deployed
([deployment.md](deployment.md)): the site runs a release commit once CI
passes on it, and a push to `main` that releases nothing waits for the
next version. [CHANGELOG.md](../CHANGELOG.md) says what each version
Expand Down Expand Up @@ -364,6 +365,25 @@ release commit. CI's run on a release commit has a concurrency group of
its own, so a newer push to `main` can't replace it while it waits
([testing.md](testing.md#continuous-integration)).

While the merge queue for `main` has entries, a push run releases
nothing. A release commit moves `main` under the queue, and GitHub
builds every queued pull request again on top of it
([testing.md](testing.md#the-merge-queue)), so each release would cost a
full run per entry. Before the job that releases, the workflow's
`queue` job asks GitHub's GraphQL API how many entries the queue holds
(`mergeQueue(branch: "main") { entries { totalCount } }`), and the
release goes ahead only when it holds none. That job holds no secret
and no environment, and its token can only read. When the query fails,
the job fails, and the run with it: it neither releases nor holds back
in silence.

So the push run of the last pull request to leave the queue releases
every commit since the last tag, as one version: during a busy queue,
several merges come out as one release and one deploy. If that last
entry fails, or is removed from the queue, no push follows it, and
`main`'s commits wait for the next merge, or for a run by hand, which
releases `main`'s tip whatever the queue holds.

Releases go one at a time (concurrency group `release`), and a running
one is never canceled. GitHub keeps only one run waiting in the group:
a newer one replaces it, and whichever goes ahead releases the tip. A
Expand Down Expand Up @@ -517,7 +537,8 @@ The workflow's run says which step failed and why; nothing opens or
comments on an issue. Once the cause is fixed, the maintainer runs the
workflow by hand (Actions › Release › Run workflow, from `main`), which
refuses until CI has passed on `main`'s tip. The next push to `main`
that passes CI tries again.
that passes CI, with the merge queue empty, tries again. A run whose
`queue` job failed released nothing, and is handled the same way.

- **Before the push** (a wrong configuration, a file the release
couldn't write, docs that couldn't be rendered or drawn, Chromium
Expand Down
17 changes: 12 additions & 5 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,13 +742,20 @@ names the pull request.
A queue run checks what a pull request run checks, except that
`commits` lints nothing, `dependency-review` doesn't run, and
`library` compares with the commit of `main` the queue built on, the
pull requests ahead included. Each queue
branch is its own concurrency group. A queue run releases and deploys
nothing: release.yml and deploy.yml follow push runs on `main` alone.
pull requests ahead included. A queue run releases and deploys
nothing: release.yml and deploy.yml follow push runs on `main` alone,
and release.yml releases only once the queue is empty, so a release
commit doesn't send the queued pull requests back to the start.

A newer run for a pull request or branch cancels the one still going.
Runs on `main` are never canceled: a release follows a push run that
passed ([releasing.md](releasing.md#what-a-release-does)), and the
So does a queue run of a pull request the queue built again, as it does
when `main` moves under it or an entry ahead leaves: the run it
replaced no longer counts. Each build has a branch of its own,
`gh-readonly-queue/main/pr-<N>-<base>`, named after the commit of
`main` it was built on. So a queue run's concurrency group is keyed on
its merge commit's message, `Merge pull request #<N> from …`, which
every build of one pull request keeps. Runs on `main` are never
canceled: a release follows a push run that passed ([releasing.md](releasing.md#what-a-release-does)), and the
deploy one on the release commit
([deployment.md](deployment.md#github-pages-automatic)). Each event has
its own concurrency group, so a run by hand doesn't cancel a push run,
Expand Down
29 changes: 26 additions & 3 deletions scripts/releaseWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import { describe, expect, it } from "vitest";
* once its files match the site job's attestation, which no other job
* of a run can make. ci.yml's check job and the release job check the
* registry's signatures of what they install, the release job before
* Chromium and the key (docs/testing.md#the-workflows).
* Chromium and the key (docs/testing.md#the-workflows). A push run
* releases only while the merge queue is empty, which a job that holds
* nothing asks (docs/releasing.md#what-a-release-does).
*/

const ROOT = join(import.meta.dirname, "..");
Expand All @@ -42,8 +44,29 @@ const workflow = jobs(read(".github/workflows/release.yml"));
const release = [...workflow.values()].flatMap((job) => steps(job, 6));

describe("release.yml", () => {
it("has the release and docs jobs", () => {
expect([...workflow.keys()]).toEqual(["release", "docs"]);
it("has the queue, release and docs jobs", () => {
expect([...workflow.keys()]).toEqual(["queue", "release", "docs"]);
});

// The queue job runs on workflow_run, beside the job that holds the
// key, so it holds nothing: no secret, no environment, no checkout,
// and a token that can only read.
it("asks for the merge queue's entries in a job that holds nothing", () => {
const queue = workflow.get("queue") ?? "";
expect(queue).toMatch(/^ {4}permissions:\n {6}contents: read\n {4}outputs:$/m);
expect(queue).not.toMatch(/secrets\.|environment:|uses:/);
expect(queue).toContain('mergeQueue(branch: "main") { entries { totalCount } }');
});

// A run by hand releases main's tip whatever the queue holds; a push
// run, only once the queue job found it empty.
it("releases after a push run only when the merge queue is empty", () => {
const job = workflow.get("release") ?? "";
expect(job).toMatch(/^ {4}needs: queue$/m);
expect(job).toMatch(/^ {6}!cancelled\(\) && \($/m);
expect(job).toContain(
"needs.queue.result == 'success' && needs.queue.outputs.entries == '0'))",
);
});

it("uses no cache action", () => {
Expand Down
Loading