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
34 changes: 34 additions & 0 deletions .github/land.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// The `land` environment holds the key of the App that main's ruleset lets bypass it,
// so only the Land workflow's land job may name an environment. Any other environment
// reference counts, since an expression can spell `land` without the word.
import { deepStrictEqual } from "node:assert";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

type Job = { environment?: unknown; uses?: unknown };

/** The `workflow:job` of every job that names an environment or calls another repository's workflow, which could. */
function users(name: string, text: string): string[] {
const jobs: Record<string, Job> = Bun.YAML.parse(text)?.jobs ?? {};
return Object.entries(jobs)
.filter(([, job]) => job.environment !== undefined || (typeof job.uses === "string" && !job.uses.startsWith("./")))
.map(([id]) => `${name}:${id}`);
}

const fixtures: [string, string[]][] = [
["jobs:\n build:\n environment: land\n", ["ci.yml:build"]],
["jobs:\n build:\n environment:\n name: land\n", ["ci.yml:build"]],
["jobs:\n build: { environment: { name: land } }\n", ["ci.yml:build"]],
["jobs:\n build:\n environment: ${{ format('{0}nd', 'la') }}\n", ["ci.yml:build"]],
["jobs:\n build:\n uses: someone/else/.github/workflows/steal.yml@main\n", ["ci.yml:build"]],
["jobs:\n build:\n uses: ./.github/workflows/check.yml\n", []],
["jobs:\n build:\n runs-on: ubuntu-latest\n", []],
];
for (const [text, expected] of fixtures) deepStrictEqual(users("ci.yml", text), expected, text);

// GitHub runs both extensions.
const workflows = join(import.meta.dir, "workflows");
const found = readdirSync(workflows)
.filter((name) => /\.ya?ml$/.test(name))
.flatMap((name) => users(name, readFileSync(join(workflows, name), "utf8")));
deepStrictEqual(found, ["land.yml:land"], "only land.yml's land job may name an environment");
6 changes: 2 additions & 4 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ name: Check
permissions:
contents: read

# Not on a push to main: main only fast-forwards to a tree land.yml already checked.
on:
pull_request:
merge_group:
push:
branches:
- main
workflow_dispatch:

# Cancel in-flight runs when a new commit lands on the same ref.
concurrency:
Expand Down
107 changes: 107 additions & 0 deletions .github/workflows/land.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Land

# `quest land` batches the PRs queued with `quest merge --land`: it squashes them
# onto main, checks the chain once, and fast-forwards main to the passing prefix.
on:
pull_request_target:
types: [auto_merge_enabled]
workflow_dispatch:

# One run at a time; waiting triggers collapse into one run that reads the whole queue.
concurrency:
group: land

permissions: {}

jobs:
resolve:
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
permissions: { contents: read, pull-requests: read, checks: read, statuses: read }
outputs:
plan: ${{ steps.resolve.outputs.plan }}
steps:
# Every job checks out main, so quest comes from a pin no queued PR can modify.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/nix
- name: Build quest
run: |
nix build --out-link "$RUNNER_TEMP/quest" .#quest
echo "$RUNNER_TEMP/quest/bin" >> "$GITHUB_PATH"
- id: resolve
env:
GH_TOKEN: ${{ github.token }}
run: echo "plan=$(quest land resolve)" >> "$GITHUB_OUTPUT"

check:
needs: resolve
runs-on: ubuntu-24.04-arm
# Room for a few runs of Check's steps: a failing chain is bisected until a prefix passes.
timeout-minutes: 90
# This job runs the queued PRs' code, so it holds only a read token.
permissions: { contents: read }
outputs:
result: ${{ steps.check.outputs.result }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/nix
# Built outside the checkout before the chain replaces it.
- name: Build quest
run: |
nix build --out-link "$RUNNER_TEMP/quest" .#quest
echo "$RUNNER_TEMP/quest/bin" >> "$GITHUB_PATH"
# The same steps as check.yml's Check job.
- id: check
env:
PLAN: ${{ needs.resolve.outputs.plan }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
printf '%s' "$PLAN" > "$RUNNER_TEMP/plan.json"
echo "result=$(quest land check "$RUNNER_TEMP/plan.json" -- nix develop --command bash -c 'just check && just test')" >> "$GITHUB_OUTPUT"

land:
needs: [resolve, check]
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
# The environment, which only main can deploy to, holds the key of the App
# that main's ruleset lets bypass it. Only this job may use it.
environment: land
# contents: write lets github.token disable auto-merge when it ejects a PR.
permissions: { contents: write, pull-requests: write, issues: write, actions: write, checks: read, statuses: read }
steps:
- id: app
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.LAND_APP_CLIENT_ID }}
private-key: ${{ secrets.LAND_APP_PRIVATE_KEY }}
# Workflows lets it land PRs that change this directory.
permission-contents: write
permission-workflows: write
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ steps.app.outputs.token }}
- uses: ./.github/actions/nix
- name: Build quest
run: |
nix build --out-link "$RUNNER_TEMP/quest" .#quest
echo "$RUNNER_TEMP/quest/bin" >> "$GITHUB_PATH"
- env:
GH_TOKEN: ${{ github.token }}
PLAN: ${{ needs.resolve.outputs.plan }}
RESULT: ${{ needs.check.outputs.result }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
printf '%s' "$PLAN" > "$RUNNER_TEMP/plan.json"
printf '%s' "$RESULT" > "$RUNNER_TEMP/result.json"
quest land push "$RUNNER_TEMP/plan.json" "$RUNNER_TEMP/result.json" > "$RUNNER_TEMP/landing"
cat "$RUNNER_TEMP/landing"
# The queue changed, so run again for the rest.
if [ -s "$RUNNER_TEMP/landing" ]; then gh workflow run land.yml --ref "$GITHUB_REF_NAME"; fi
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Commits

PRs into `main` are squash-merged, so the PR title becomes the commit subject and the PR description becomes the body.
PRs land on `main` only through `quest land`, each as one squash commit: the PR title becomes the commit subject and the PR's commit messages become the body.
Queue a PR with `quest merge <pr> <token> --land <sha>` (`/quest-merge` does this), and the Land workflow runs Check's steps once on the batch, landing the longest passing prefix.
The admin bypass of `main`'s ruleset is for emergencies.

- Use conventional-commit subjects (`feat(ts): ...`, `fix: ...`, `chore: ...`, `docs: ...`).
- AI commit attribution goes in a `Co-Authored-By:` trailer, not the commit body.
Expand Down Expand Up @@ -29,7 +31,7 @@ For each finding:
- If you don't agree with it, reply to the finding and move on.
- If it's a relatively easy improvement, fix it and push.

Merge only after a review from a reviewer other than Grok has no findings, or every finding is fixed or replied to.
Queue a PR only after a review from a reviewer other than Grok has no findings, or every finding is fixed or replied to.

# Follow-ups

Expand Down
1 change: 1 addition & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ check:
# Run the tests.
test:
bash .claude/hooks/direnv.test.sh
bun .github/land.test.ts
just ts test
just ffi test
just driver test
Expand Down
29 changes: 21 additions & 8 deletions quest/m1/land.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# [S] Land main through quest land
# [XS] Land main through quest land

## Goal

Expand All @@ -13,10 +13,23 @@ Decided 2026-10-26 (maintainer), planning moq-dev/moq's merge throughput
([moq-dev/moq#6854](https://github.com/moq-dev/moq/pull/6854)): the batched
gate is shared through kixelated/quest, and this repository adopts it.

- The push credential is GitHub Actions in a new `main` ruleset's bypass
list, guarded by a test that refuses `contents: write` anywhere but the
land job, as moq.pro's `queue.test.ts` does. The admin role keeps its
emergency bypass. Ruleset writes are the maintainer's.
- The ruleset requires a check that PR heads never carry, so auto-merge only
waits for the gate. `check.yml` drops its `merge_group` trigger.
- Update the contributor docs that describe landing.
[kixelated/rust2#137](https://github.com/kixelated/rust2/pull/137) added the
Land workflow, a test that only its land job names an environment, and
`CONTRIBUTING.md`'s landing rule. What remains is the maintainer's setup and
the live verification.

- **Push credential (maintainer).** A GitHub App installed on this repository
is the `main` ruleset's bypass actor, with Contents and Workflows write
(Workflows lets it land PRs that change `.github/workflows`) and no
webhook. GitHub Actions cannot be: rulesets refused it as a bypass actor on
a personal repository, kixelated/quest, on 2026-10-27. The `land`
environment, deployable only from `main`, holds the App's client ID as the
variable `LAND_APP_CLIENT_ID` and its key as the secret
`LAND_APP_PRIVATE_KEY`.
- **Ruleset (maintainer).** A new `main` ruleset blocks deletion and
non-fast-forward pushes and requires two checks: `Check` from GitHub
Actions, and `quest land` pinned to the land App, which has no checks or
statuses permission and so never reports it. Its bypass actors are the land
App and the admin role, which keeps an emergency bypass. Ruleset writes are
the maintainer's.
- The PR that finishes this quest lands through the gate, verifying it.