Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9f87753
chore(quest): claim quest/m0/queue-watcher/own-queue.md
kixelated Nov 7, 2026
92fe301
wip: own serial queue in quest land watch
kixelated Nov 7, 2026
01a2b7f
wip: own queue tests, docs, quest removal
kixelated Nov 7, 2026
8c8ce2a
Merge remote-tracking branch 'origin/main' into quest/m0/queue-watche…
kixelated Nov 7, 2026
5a8fe79
fix(land): read the queue mode with a read-only token, and cancel orp…
kixelated Nov 7, 2026
39f8659
chore(quest): claim quest/m0/pin-sync.md
kixelated Nov 7, 2026
848330c
Merge remote-tracking branch 'origin/quest/m0/queue-watcher/own-queue…
kixelated Nov 7, 2026
1b87b94
feat(land): the quest watcher bumps quest's pin in other repositories
kixelated Nov 7, 2026
e66fdec
Merge remote-tracking branch 'origin/main' into quest/m0/pin-sync
kixelated Nov 7, 2026
22f1264
quest: drop per-base's links to the own-queue quest the merged branch…
kixelated Nov 7, 2026
94e0e71
fix(land): contain a bump's tools, and wait for whole check suites on…
kixelated Nov 7, 2026
b7fd500
fix(land): an ungated bump waits for the head's workflow runs
kixelated Nov 7, 2026
a1c3b85
Merge origin/main; an ungated bump counts a superseded cancellation a…
kixelated Nov 8, 2026
9dd09c2
fix(land): a bump checks symlinks by full name, keeps history, and fo…
kixelated Nov 8, 2026
7ca104c
test(land): skip the tab-named symlink fixture on Windows
kixelated Nov 8, 2026
c73bca7
fix(land): rebuild a cached quest that Nix's garbage collection removed
kixelated Nov 8, 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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,55 @@ cancel runs, `checks: write` for its check runs, `statuses: read`, and
does. Its pushes ask for `contents: write`, and `workflows: write` for one that
carries a workflow change.

### Pin bumps

With `--bump <owner/repo>`, repeatable, the watcher keeps each named
repository's flake input on the watched repository at the watched base, so
every push to the base reaches those repositories with no one touching them.
It needs `nix` on `PATH`. Once a pass sees the base at a commit a target does
not pin yet, it opens or updates that target's one `quest-bump` PR as the App:

1. It reads the target's `flake.lock` on its default branch for the one root
input that locks the watched repository on GitHub; a target already at the
commit gets nothing, and an open bump PR there is closed.
2. In a fresh shallow checkout, which it refuses when a tracked symlink leaves
the checkout or dangles, since the tools write through symlinks, it drops a
revision from that input's URL in
`flake.nix`, so `flake.lock` alone pins it from then on, and locks the input
at the commit with `nix flake lock --override-input`.
3. It builds the watched repository's `quest` at that commit with `nix build`,
and runs its `quest init` and `quest check --fix` on the checkout with
`--root`, from a directory of its own, so the target's `bunfig.toml` never
loads.
4. It commits the result as moq-bot and lease-pushes it to `quest-bump`. An open
bump PR's head becomes the new commit's first parent, beside the target's
base, so the branch never rewinds; any other `quest-bump` branch, left by a
merged or closed PR, is replaced. Auto-merge on the open PR is disarmed
before the push. The PR body links the comparison and records the review as
skipped, since a pin bump changes no repository code and the target's CI is
the gate.
5. Findings `quest check --fix` cannot resolve leave the PR a draft with a
`## Waiting` item quoting them. Otherwise it lands like any PR there, through
`quest merge`'s path: into GitHub's merge queue, the target's own queue, or
auto-merge. A base that requires no check would merge at once, so there the
bump first waits for every check and workflow run on its head to pass, and
for every other app's check suite that ran a check to complete, then merges
directly
when the repository has no auto-merge, as on a private repository on GitHub
Free, whose rules GitHub does not serve. A failed check, or a landing
`quest merge` refuses, is commented on and left for a maintainer.

From there the target's own gate and fixer take the PR, as any other; a newer
commit on the base replaces it with a fresh bump. `bump.json` in the state
directory records each target's last bump, so a restart repeats nothing and a
closed bump is not reopened for the same commit. A target's failure is logged
and retried on the next pass without holding up the others. Each target gets
its own tokens: `contents: write`, `pull_requests: write`, `merge_queues:
write`, and reads of `actions`, `administration`, `checks`, and `statuses` for its API
calls, and `workflows: write` for a push whose diff from the branch's old head
carries a workflow change. Nix reads GitHub with the watched repository's
`contents: read` token.

## Gate expensive commands

`quest gate <command> [args...]` holds an expensive command until the host has
Expand Down
85 changes: 36 additions & 49 deletions quest/m0/pin-sync.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,45 @@
# [L] Repositories and hosts follow quest's main
# [S] Hosts run quest's pin bumps and upgrade on their own

## Goal

Every push to moq-dev/quest's `main` reaches each enrolled repository and
each host without a person or an agent touching it. The maintainer's host
opens or updates one pin-bump PR per repository, as moq-bot, and queues it.
kixelated/nixos is a target too, and its hosts apply its `main` on their
own, so the queue watcher and gate run the same quest within the hour.
Every push to moq-dev/quest's `main` reaches each repository that pins quest,
and each host, without a person or an agent touching it: the quest watcher on
the maintainer's host runs `quest land watch --bump` for every such
repository, kixelated/nixos included, and each host applies kixelated/nixos's
`main` through `system.autoUpgrade`, so the queue watchers run the quest
`main` has within the hour.

## Plan

Decided 2026-11-05 (user, /quest-plan, after the
[token waste](/quest/m0/token-waste/README.md) plan merged): "it's kind of
annoying to need to update every PR and repo."

Measured that day: moq-dev/moq, studio, and rind pinned quest at
`4be3508`, rust2 at `dc8008b`, kixel.ai at `5790eea`, and kixelated/nixos at
`91714ab`; moq.pro and quest itself track `main` through their lockfiles. Each
dev shell puts its pinned `quest` first on `PATH`, so a skill or rule change
reaches a repository's agents only after a bump PR there. The NixOS hosts
change only on a manual `nixos-rebuild switch`. Machines' dotfiles now pull
themselves hourly ([kixelated/dotfiles#42](https://github.com/kixelated/dotfiles/pull/42)),
so this quest covers only the repositories and hosts.

Decision prompts, chosen options marked ✅:

- How updates reach repositories: ✅ the host bot bumps (rejected:
Dependabot with auto-merge, which lags a day and needs a write token in
each repository's workflows; unpinning agent content to a user-level
`quest`, which lets two versions disagree on the format). The nixos host
already holds moq-bot's App credentials and the enrolled repositories.
- Review of a bump PR: ✅ the bot records a `## Review` skip, since a pin
bump changes no repository code and each repository's CI, running
`quest check` with the new quest, is the gate (rejected: a Codex round per
bump).
- A format change: ✅ the bot runs `quest check --fix` with the new quest and
commits the result in the bump PR; a finding `--fix` cannot resolve leaves
the PR open with a `## Waiting` item (rejected: hand the failing PR to an
agent).
- Cadence: ✅ every push to `main`, updating the one open bump PR per
repository so several merges coalesce (rejected: tagged releases only).
- Hosts: ✅ kixelated/nixos is a bump target, and each host enables
`system.autoUpgrade` from its `main` (rejected: bump with manual deploys;
leave the hosts).

Recommendations for the implementer:

- Pin every repository the same way, a branch URL with the revision only in
`flake.lock`, so a bump is one `nix flake update` of the quest input.
- Reuse the queue watcher's App client, enrolled-repository list, and
enqueue path rather than a second daemon's.
- The kixelated/nixos half (the bump target and `system.autoUpgrade`) is a
PR there; check that an upgrade does not restart the queue watcher in the
middle of a fixer job.
The bump pass itself is done (README, "Pin bumps"). What remains is the host
configuration in kixelated/nixos, drafted on its `quest-pin-sync` branch:

- `modules/quest-land.nix` passes `--bump` to the quest watcher for every other
land repository plus moq-dev/studio, moq-dev/kixel.ai, and kixelated/nixos,
and puts Nix on its `PATH`.
- `modules/base.nix` enables `system.autoUpgrade` hourly on both hosts from
`git+ssh://git@github.com/kixelated/nixos?ref=main#<host>`, fetched with
each host's SSH host key as a read-only deploy key.
- Check and fixer containers already survive a switch
(`restartIfChanged = false`), and a watcher's detached fixer supervisor
outlives its daemon's restart. The batching gate stops its check with it, so
it is not restarted by a switch until moq.pro moves onto the watcher
([moq.pro cutover](/quest/m0/queue-watcher/pro-cutover.md)).

Once the bump pass is on quest's `main`, bump that branch's quest input to it
(the watcher rejects `--bump` before then), land it, and switch each host by
hand once; from then on the hosts upgrade themselves.

## Waiting

- Maintainer: add each host's `/etc/ssh/ssh_host_ed25519_key.pub` (nixos and
nixos2) to kixelated/nixos as a read-only deploy key, since only the
repository's owner can grant access to it; check that
`sudo systemctl start nixos-upgrade` succeeds on each host.
- Maintainer: install moq-bot on the kixelated account for kixelated/nixos,
with the permissions it has on moq-dev, since an App installation needs the
account owner's consent; check that the quest watcher's journal shows
`bump kixelated/nixos` rather than a failed token request.

## Related

Expand Down
19 changes: 18 additions & 1 deletion src/cli/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,23 @@ export interface Requirements {
classic: boolean;
}

/**
* Every rule a branch's active rulesets apply. GitHub Free offers no rulesets on a private repository and
* answers 403 with an upgrade notice, so none apply there; any other refusal fails.
*/
async function branchRules(github: GitHub, path: string): Promise<ObjectValue[]> {
const first = await github.request(path, { allow: [403] });
if (first.status !== 403) {
const next = link(first, "next");
return [...array(await first.json()), ...(next ? await github.all(next) : [])];
}
const reason = explanation(await first.text());
if (reason?.startsWith("Upgrade to GitHub Pro")) return [];
throw new Error(
`GitHub HTTP 403 for ${new URL(path, API).pathname}; ${reason ?? "check repository access and token permissions"}`,
);
}

/** What `branch` of `repository` requires, read with read access from branch protection and rulesets. */
export async function requirements(github: GitHub, repository: Repository, branch: string): Promise<Requirements> {
const base = `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
Expand All @@ -512,7 +529,7 @@ export async function requirements(github: GitHub, repository: Repository, branc
app: null,
}))
: array(legacy.checks).map((row) => ({ name: string(row.context), app: source(row.app_id) }));
const rules = await github.all(`${base}/rules/branches/${ref}?per_page=100`);
const rules = await branchRules(github, `${base}/rules/branches/${ref}?per_page=100`);
const checks = [
...classic,
...rules
Expand Down
Loading
Loading