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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,10 +974,16 @@ 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
With `--bump`, the watcher keeps the flake input on the watched repository at
the watched base in every other repository the App is installed on, so every
push to the base reaches those repositories with no one touching them.
Installing the App on a repository enrolls it and uninstalling it stops its
bumps; there is no list to keep. Each time the base moves, a pass lists every
installation's repositories, reads each one's `flake.lock` on its default
branch without cloning it, and keeps those that lock the watched repository
through exactly one root input as targets; a repository locking it through
several is reported and skipped. Discovery needs the installations to grant
`contents: read`. 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
Expand Down
30 changes: 0 additions & 30 deletions quest/m0/pin-bumps/discover.md

This file was deleted.

95 changes: 90 additions & 5 deletions src/cli/land-bump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export interface BumpConnection {

export interface BumpContext {
source: Repository;
targets: Repository[];
/** Every App installation's API access, reading all of its repositories, from which a pass finds its targets. */
installations: () => Promise<GitHubDependencies[]>;
/** The targets found for the base commit last bumped, so discovery reruns only once the base moves. */
discovered?: { sha: string; targets: Repository[] };
state: string;
connect: (target: Repository) => BumpConnection;
/** A read-only token for the source, which the tools fetch from GitHub. */
Expand All @@ -84,11 +87,11 @@ const PASSING = ["success", "neutral", "skipped"];

const name = (repository: Repository) => `${repository.owner}/${repository.repo}`;

/** The root input of `lock` that locks `source` on GitHub, and its locked and URL-pinned revisions. */
export function questInput(lock: ObjectValue, source: Repository): { input: string; locked: string; pinned?: string } {
/** Every root input of `lock` that locks `source` on GitHub, and its locked and URL-pinned revisions. */
function questInputs(lock: ObjectValue, source: Repository): { input: string; locked: string; pinned?: string }[] {
const nodes = object(lock.nodes);
const inputs = object(object(nodes[string(lock.root)]).inputs ?? {});
const matches = Object.entries(inputs).flatMap(([input, node]) => {
return Object.entries(inputs).flatMap(([input, node]) => {
// A follows path names another node's input, not a lock of its own.
if (typeof node !== "string") return [];
const entry = object(nodes[node]);
Expand All @@ -105,13 +108,88 @@ export function questInput(lock: ObjectValue, source: Repository): { input: stri
},
];
});
}

/** The root input of `lock` that locks `source` on GitHub, and its locked and URL-pinned revisions. */
export function questInput(lock: ObjectValue, source: Repository): { input: string; locked: string; pinned?: string } {
const matches = questInputs(lock, source);
if (matches.length !== 1)
throw new Error(
`flake.lock has ${matches.length} root inputs locking github:${name(source)}, where a bump needs exactly one`,
);
return matches[0];
}

/**
* The repositories, other than the source, across every installation whose default branch's `flake.lock`
* locks the source through exactly one root input. Only the lock is read; nothing is cloned. A repository
* locking it through several inputs, or whose lock cannot be decoded, is reported in `output` and skipped. A failed
* request, for one repository's lock or an installation's token or listing, is recorded in `failures`, and
* the rest are still searched.
*/
export async function discoverTargets(
source: Repository,
installations: GitHubDependencies[],
output: string[],
failures: string[],
): Promise<Repository[]> {
const targets: Repository[] = [];
for (const [index, deps] of installations.entries()) {
const github = new GitHub(deps);
try {
for (let page = 1; ; page++) {
const listed = object(
await (await github.request(`/installation/repositories?per_page=100&page=${page}`)).json(),
);
const rows = array(listed.repositories);
for (const row of rows) {
const owner = string(object(row.owner).login);
const repo = string(row.name);
const target = { owner, repo };
if (row.archived === true || name(target).toLowerCase() === name(source).toLowerCase()) continue;
let response: Response;
try {
response = await github.request(`/repos/${owner}/${repo}/contents/flake.lock`, {
allow: [404],
});
} catch (error) {
// A failed request may be transient, so it leaves discovery incomplete and retried.
failures.push(
`discovery of ${name(target)}: ${error instanceof Error ? error.message : String(error)}`,
);
continue;
}
if (response.status === 404) continue;
let found: number;
try {
const content = string(object(await response.json()).content);
found = questInputs(
object(JSON.parse(Buffer.from(content, "base64").toString("utf8"))),
source,
).length;
} catch (error) {
output.push(
`bump skips ${name(target)}: its flake.lock cannot be read: ${error instanceof Error ? error.message : String(error)}`,
);
continue;
}
if (found === 1) targets.push(target);
else if (found > 1)
output.push(
`bump skips ${name(target)}: flake.lock locks github:${name(source)} through ${found} root inputs`,
);
}
if (rows.length < 100) break;
}
} catch (error) {
failures.push(
`discovery in installation ${index + 1}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return targets;
}

/**
* `flake` with the revision dropped from the source's URL, so flake.lock alone pins the input and each
* bump is a lock-only change. The URL must appear exactly once.
Expand Down Expand Up @@ -206,7 +284,14 @@ export async function bumpPass(context: BumpContext, sha: string): Promise<strin
const bumps = readBumps(context.state);
const output: string[] = [];
const failures: string[] = [];
for (const target of context.targets) {
let targets = context.discovered?.sha === sha ? context.discovered.targets : undefined;
if (!targets) {
const missed = failures.length;
targets = await discoverTargets(context.source, await context.installations(), output, failures);
// A partial discovery is not cached, so the next pass searches the failed installations again.
context.discovered = failures.length === missed ? { sha, targets } : undefined;
}
for (const target of targets) {
const key = name(target);
if (bumps[key]?.sha === sha && bumps[key].status !== "checking") continue;
try {
Expand Down
52 changes: 39 additions & 13 deletions src/cli/land-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,16 @@ import { createPrivateKey, createSign } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { GitHub, type GitHubDependencies, type Repository, object, string } from "../github/client";
import { array, GitHub, type GitHubDependencies, type Repository, object, string } from "../github/client";
import { git } from "./land";

/** Mint a short-lived token for this repository, never the installation's other repositories. */
export async function installationToken(
repository: Repository,
options: { appId: string },
permissions: Record<string, "read" | "write">,
deps: Pick<GitHubDependencies, "fetch" | "now">,
): Promise<string> {
/** A request to GitHub's API as the App itself, signed with its key. */
function appRequest(options: { appId: string }, deps: Pick<GitHubDependencies, "fetch" | "now">) {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const now = Math.floor(deps.now() / 1000);
const payload = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ iat: now - 60, exp: now + 540, iss: options.appId })}`;
const signature = createSign("RSA-SHA256").update(payload).sign(readFileSync(keyPath())).toString("base64url");
const request = async (path: string, body?: unknown) => {
return async (path: string, body?: unknown): Promise<unknown> => {
const response = await deps.fetch(`https://api.github.com${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Expand All @@ -29,19 +24,50 @@ export async function installationToken(
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) throw new Error(`GitHub App request failed: HTTP ${response.status}`);
return object(await response.json());
return response.json();
};
const installation = await request(`/repos/${repository.owner}/${repository.repo}/installation`);
}

/** Mint a short-lived token for this repository, never the installation's other repositories. */
export async function installationToken(
repository: Repository,
options: { appId: string },
permissions: Record<string, "read" | "write">,
deps: Pick<GitHubDependencies, "fetch" | "now">,
): Promise<string> {
const request = appRequest(options, deps);
const installation = object(await request(`/repos/${repository.owner}/${repository.repo}/installation`));
return string(
(
object(
await request(`/app/installations/${Number(installation.id)}/access_tokens`, {
repositories: [repository.repo],
permissions,
})
}),
).token,
);
}

/** The App's installations, each as a function minting a token for all of that installation's repositories. */
export async function installations(
options: { appId: string },
deps: Pick<GitHubDependencies, "fetch" | "now">,
): Promise<((permissions: Record<string, "read" | "write">) => Promise<string>)[]> {
const ids: number[] = [];
for (let page = 1; ; page++) {
const rows = array(await appRequest(options, deps)(`/app/installations?per_page=100&page=${page}`));
// GitHub lists a suspended installation but refuses to mint its tokens.
ids.push(...rows.filter((row) => row.suspended_at == null).map((row) => Number(row.id)));
if (rows.length < 100) break;
}
return ids.map(
(id) => async (permissions) =>
string(
object(await appRequest(options, deps)(`/app/installations/${id}/access_tokens`, { permissions }))
.token,
),
);
}

/** Replace `path` atomically, readable only by the daemon's user. */
export function save(path: string, value: unknown): void {
writeFileSync(`${path}.new`, JSON.stringify(value), { mode: 0o600 });
Expand Down
70 changes: 36 additions & 34 deletions src/cli/land-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
EXCERPT,
excerpt,
installationToken,
installations,
privateRepository,
save,
startDaemon,
Expand Down Expand Up @@ -68,8 +69,8 @@ export interface WatchOptions {
fixerTag: string[];
/** Patterns of GitHub branches mirrored into each fixer checkout as `origin/<branch>`. */
fixerBranch: string[];
/** Repositories whose flake input on this repository each pass bumps to this repository's base. */
bump: Repository[];
/** Bump every repository the App is installed on whose flake input locks this repository, to this repository's base. */
bump?: boolean;
/** On the own queue, eject fork picks before pushing their build, so a fork's code never reaches a private runner. */
refuseForks?: boolean;
}
Expand Down Expand Up @@ -880,9 +881,6 @@ export async function ownQueue(github: GitHub, repository: Repository, base: str

type Permissions = Record<string, "read" | "write">;

const same = (a: Repository, b: Repository) =>
a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase();

/**
* The installation token permissions the watcher asks for. On GitHub's merge queue, `api` reads the
* queue's events, comments, and the `merge_group` logs (`actions: read`), and enqueues the armed picks
Expand Down Expand Up @@ -950,9 +948,7 @@ export async function watch(
options.fixer = resolve(options.fixer);
accessSync(options.fixer, constants.X_OK);
}
if (options.bump.length > 0) checkTools();
if (options.bump.some((target) => same(target, repository)))
throw new Error("--bump names the watched repository, which cannot bump its own pin");
if (options.bump) checkTools();
const now = { fetch, now: Date.now };
const mint = (wanted: Permissions) => installationToken(repository, options, wanted, now);
// Each mode's token is minted only once a pass needs it, and the mode is read with neither, so an
Expand Down Expand Up @@ -992,32 +988,38 @@ export async function watch(
read,
};
const connections = new Map<string, BumpConnection>();
const bump: BumpContext | undefined =
options.bump.length === 0
? undefined
: {
source: repository,
targets: options.bump,
state: options.state,
reader: () => mint({ contents: "read" }),
tools: hostTools(repository, options.state),
connect: (target) => {
const key = `${target.owner}/${target.repo}`;
let connection = connections.get(key);
if (connection === undefined) {
const scoped = (wanted: Permissions) => installationToken(target, options, wanted, now);
const push: Permissions = { contents: "write", pull_requests: "write" };
connection = {
remote: `https://github.com/${key}.git`,
deps: apiDependencies(() => scoped(BUMP_PERMISSIONS), { ...now, sleep, warn }),
token: (workflows) => scoped(workflows ? { ...push, workflows: "write" } : push),
read: authenticatedRead(() => scoped({ contents: "read" })),
};
connections.set(key, connection);
}
return connection;
},
};
const bump: BumpContext | undefined = !options.bump
? undefined
: {
source: repository,
installations: async () =>
(await installations(options, now)).map((mintAll) =>
apiDependencies(() => mintAll({ contents: "read", metadata: "read" }), {
...now,
sleep,
warn,
}),
),
state: options.state,
reader: () => mint({ contents: "read" }),
tools: hostTools(repository, options.state),
connect: (target) => {
const key = `${target.owner}/${target.repo}`;
let connection = connections.get(key);
if (connection === undefined) {
const scoped = (wanted: Permissions) => installationToken(target, options, wanted, now);
const push: Permissions = { contents: "write", pull_requests: "write" };
connection = {
remote: `https://github.com/${key}.git`,
deps: apiDependencies(() => scoped(BUMP_PERMISSIONS), { ...now, sleep, warn }),
token: (workflows) => scoped(workflows ? { ...push, workflows: "write" } : push),
read: authenticatedRead(() => scoped({ contents: "read" })),
};
connections.set(key, connection);
}
return connection;
},
};
await daemonLoop(async () => {
pruneLogs(options.state, deps.now(), deps.warn);
const api = new GitHub(probe);
Expand Down
12 changes: 3 additions & 9 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { held, openDrafts, reach, target, upstream, upstreamRefs } from "./claim
import { PENDING_MS } from "./admission";
import { gate, QUICK_MS } from "./gate";
import { gitHubDependencies, GitHubInputError, pullRequest, readRemote, remoteRepository } from "./github";
import { mergedHolds, type Repository } from "../github/client";
import { mergedHolds } from "../github/client";
import { type Hold, finish, land, release, take } from "./hold";
import { LAND_CHECK } from "../github/land";
import { apply, pushCommand } from "./land-apply";
Expand Down Expand Up @@ -789,14 +789,8 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"On the own queue, eject each pick whose head is in a fork, or in a deleted repository, before pushing its build, so a fork's code never reaches a private queue runner",
)
.option(
"--bump <owner/repo>",
"Keep this repository's flake input in another repository at this repository's base (repeatable): each pass opens or updates that repository's quest-bump PR as the App and lands it",
(value: string, previous: Repository[]) => {
const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value);
if (!match) throw new InvalidArgumentError("expected owner/repo");
return [...previous, { owner: match[1], repo: match[2] }];
},
[] as Repository[],
"--bump",
"Keep this repository's flake input, in every other repository the App is installed on whose flake.lock locks this repository through exactly one root input, at this repository's base: each pass opens or updates that repository's quest-bump PR as the App and lands it",
)
.action((options: WatchOptions) => {
const origin = readRemote(root(), "origin");
Expand Down
Loading
Loading