diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 288c30498..a4e59697b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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--, +# 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 # +# 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 @@ -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' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99af25d37..f2a362d66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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] @@ -56,8 +65,63 @@ 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' && @@ -65,7 +129,8 @@ jobs: 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 diff --git a/docs/releasing.md b/docs/releasing.md index 0d582477b..10ac609f0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 @@ -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 @@ -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 diff --git a/docs/testing.md b/docs/testing.md index 0e5d29f9f..c55726b28 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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--`, 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 # 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, diff --git a/scripts/releaseWorkflow.test.ts b/scripts/releaseWorkflow.test.ts index ad1e98a0d..e04e9b785 100644 --- a/scripts/releaseWorkflow.test.ts +++ b/scripts/releaseWorkflow.test.ts @@ -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, ".."); @@ -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", () => {