From 1cfd0a7d0be12026c2bcdd761eef693edf04c3f1 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:36:24 +0100 Subject: [PATCH 01/27] fork: the server and CLI update from the desktop app's repository (#1) * fork: builds name the repository they update from (T3CODE_RELEASE_REPOSITORY) A build made with T3CODE_RELEASE_REPOSITORY=/ lists, downloads and links releases only from that repository: the server's `t3 update`, the Update button's pinned-runtime download, the desktop app's SSH installs, and the desktop app's own electron-updater feed. Without the setting nothing changes (upstream pingdotgg/t3code). A malformed value fails the build instead of quietly falling back to upstream. Co-Authored-By: Claude Opus 5.5 * fork: a GitHub Actions build names the repository it runs in Without T3CODE_RELEASE_REPOSITORY, an Actions build takes GITHUB_REPOSITORY, as the desktop updater feed already does. Reusable workflows (release-desktop.yml re-bundles the server for the CLI executable) do not pass the caller's env on, so the explicit variable alone would let a fork's CLI archive name upstream. Co-Authored-By: Claude Opus 5.5 * fork: the server follows upstream's desktop update repository setting Review round 1: a second setting (T3CODE_RELEASE_REPOSITORY) with the opposite precedence let a build set only T3CODE_DESKTOP_UPDATE_REPOSITORY and ship a desktop app on the fork's feed with a server that updates from upstream. The server, CLI and desktop SSH installs now read upstream's own setting in upstream's order (T3CODE_DESKTOP_UPDATE_REPOSITORY, then GITHUB_REPOSITORY), so all three readers agree by construction, and the patch no longer touches build-desktop-artifact.ts or the web config's repository line. The former name is still accepted when it names the same repository and fails the build otherwise. A test checks the server's value against the desktop updater feed. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: t3code-fork Co-authored-by: Claude Opus 5.5 --- apps/desktop/vite.config.ts | 2 + apps/server/vite.config.ts | 2 + apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 2 + docs/operations/release.md | 1 + packages/shared/src/cliRelease.test.ts | 49 +++++++++++++++++- packages/shared/src/cliRelease.ts | 14 ++++- scripts/lib/release-repository.test.ts | 72 ++++++++++++++++++++++++++ scripts/lib/release-repository.ts | 45 ++++++++++++++++ 9 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 scripts/lib/release-repository.test.ts create mode 100644 scripts/lib/release-repository.ts diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 455901299edc3..a134f63660a2f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -5,6 +5,7 @@ import { defaultInclude, defineConfig } from "vite-plus"; import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { releaseRepositoryDefine } from "../../scripts/lib/release-repository.ts"; const repoEnv = loadRepoEnv(); const domTests = ["src/preview/PickPreload.test.ts"]; @@ -21,6 +22,7 @@ const devElectronCommand = ? "node scripts/dev-electron.mjs" : "exec node scripts/dev-electron.mjs"; const publicConfigDefine = { + ...releaseRepositoryDefine(), __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", ), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 02fe582953abd..ba47cc4e57418 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { releaseRepositoryDefine } from "../../scripts/lib/release-repository.ts"; import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; @@ -126,6 +127,7 @@ export default mergeConfig( js: "#!/usr/bin/env node\n", }, define: { + ...releaseRepositoryDefine(), __T3CODE_BUILD_CHANNEL__: JSON.stringify(cliBuildChannel), __T3CODE_TELEMETRY_CHANNEL__: JSON.stringify(telemetryChannel), __T3CODE_BUILD_RELAY_URL__: JSON.stringify(repoEnv.T3CODE_RELAY_URL?.trim() ?? ""), diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c60a2173e971e..dd5b5bdd70edb 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -35,6 +35,7 @@ "scripts/verify-built-boot.ts", "scripts/warm-dep-cache.ts", "../../scripts/lib/public-config.ts", + "../../scripts/lib/release-repository.ts", "../../scripts/lib/third-party-licenses.ts" ] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 89c54e307d963..752932e4e2578 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; +import { releaseRepositoryDefine } from "../../scripts/lib/release-repository"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; import { tailwindPlugins } from "./vite/tailwind"; import { warmBundledDevPlugin } from "./vite/warmBundledDev"; @@ -322,6 +323,7 @@ export default defineConfig(() => { ], }, define: { + ...releaseRepositoryDefine(), "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), // Override .env URLs with empty values in single-origin dev so clients use window.location.origin. "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), diff --git a/docs/operations/release.md b/docs/operations/release.md index be74b2ad2982e..4268bcc90d4a7 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -477,6 +477,7 @@ available. - Repository slug source: - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. - otherwise `GITHUB_REPOSITORY` from GitHub Actions. + - The server, `t3 update` and desktop SSH installs bake in the same slug (`scripts/lib/release-repository.ts`). - Required release assets for updater: - platform installers (`.exe`, `.dmg`, `.AppImage`, `.deb`, plus macOS `.zip` for Squirrel.Mac update payloads) - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index b0aa61765e45a..5b59e3b767035 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { cliArchiveTarCommand, newestCliReleaseVersion, parseChecksums } from "./cliRelease.ts"; +import { + cliArchiveTarCommand, + newestCliReleaseVersion, + parseChecksums, + resolveCliReleaseRepository, +} from "./cliRelease.ts"; describe("cliRelease", () => { it("parses sha256sum output including binary-mode markers", () => { @@ -40,3 +45,43 @@ describe("cliRelease", () => { expect(newestCliReleaseVersion([{ tag_name: "v1.2.3" }], "preview")).toBeUndefined(); }); }); + +describe("cliRelease repository", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("defaults to upstream when the build names no repository", () => { + expect(resolveCliReleaseRepository(undefined)).toBe("pingdotgg/t3code"); + expect(resolveCliReleaseRepository("")).toBe("pingdotgg/t3code"); + expect(resolveCliReleaseRepository(" ")).toBe("pingdotgg/t3code"); + }); + + it("lists and downloads releases only from the repository the build names", async () => { + vi.stubGlobal("__T3CODE_RELEASE_REPOSITORY__", "fork-owner/t3code"); + vi.resetModules(); + const release = await import("./cliRelease.ts"); + expect(release.cliReleaseIndexPageUrl(2)).toBe( + "https://api.github.com/repos/fork-owner/t3code/releases?per_page=100&page=2", + ); + expect(release.cliReleaseDownloadBaseUrl("0.1.9-fork.1")).toBe( + "https://github.com/fork-owner/t3code/releases/download/v0.1.9-fork.1", + ); + expect(release.cliReleaseAssetUrl("0.1.9-fork.1", "SHA256SUMS")).toBe( + "https://github.com/fork-owner/t3code/releases/download/v0.1.9-fork.1/SHA256SUMS", + ); + expect(release.CLI_RELEASES_PAGE_URL).toBe("https://github.com/fork-owner/t3code/releases"); + }); + + it("keeps upstream URLs in a build without the setting", async () => { + vi.resetModules(); + const release = await import("./cliRelease.ts"); + expect(release.cliReleaseIndexPageUrl(1)).toBe( + "https://api.github.com/repos/pingdotgg/t3code/releases?per_page=100&page=1", + ); + expect(release.cliReleaseDownloadBaseUrl("0.1.9")).toBe( + "https://github.com/pingdotgg/t3code/releases/download/v0.1.9", + ); + }); +}); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 07a0c73b17947..9c12b4cc426d6 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -3,7 +3,19 @@ import * as Schema from "effect/Schema"; import { compareSemverVersions } from "./semver.ts"; -const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +// Set at build time from T3CODE_DESKTOP_UPDATE_REPOSITORY (scripts/lib/release-repository.ts), the +// desktop updater's setting, so a fork's builds list and download only the fork's releases. +declare const __T3CODE_RELEASE_REPOSITORY__: string | undefined; +const UPSTREAM_RELEASE_REPOSITORY = "pingdotgg/t3code"; + +/** The repository releases come from: the build's setting, or upstream when it has none. */ +export function resolveCliReleaseRepository(configured: string | undefined): string { + return configured?.trim() || UPSTREAM_RELEASE_REPOSITORY; +} + +const CLI_RELEASE_REPOSITORY = resolveCliReleaseRepository( + typeof __T3CODE_RELEASE_REPOSITORY__ === "undefined" ? undefined : __T3CODE_RELEASE_REPOSITORY__, +); export const CLI_RELEASES_PAGE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases`; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; diff --git a/scripts/lib/release-repository.test.ts b/scripts/lib/release-repository.test.ts new file mode 100644 index 0000000000000..46a285b29425e --- /dev/null +++ b/scripts/lib/release-repository.test.ts @@ -0,0 +1,72 @@ +import { it as effectIt } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveGitHubPublishConfig } from "../build-desktop-artifact.ts"; +import { releaseRepositoryDefine, resolveReleaseRepository } from "./release-repository.ts"; + +const desktopFeedRepository = (env: Record) => + resolveGitHubPublishConfig("latest").pipe( + Effect.map((config) => (config ? `${config.owner}/${config.repo}` : "")), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ); + +describe("release repository build setting", () => { + it("is empty (the upstream default) when unset or blank", () => { + expect(resolveReleaseRepository({})).toBe(""); + expect(resolveReleaseRepository({ T3CODE_DESKTOP_UPDATE_REPOSITORY: " " })).toBe(""); + }); + + it("is upstream's desktop update setting, so the server follows the desktop feed", () => { + const env = { T3CODE_DESKTOP_UPDATE_REPOSITORY: " some-owner/t3code " }; + expect(resolveReleaseRepository(env)).toBe("some-owner/t3code"); + expect(releaseRepositoryDefine(env)).toEqual({ + __T3CODE_RELEASE_REPOSITORY__: JSON.stringify("some-owner/t3code"), + }); + }); + + it("names the repository a GitHub Actions build runs in, unless the setting overrides it", () => { + const actions = { GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "fork-owner/t3code" }; + expect(resolveReleaseRepository(actions)).toBe("fork-owner/t3code"); + expect( + resolveReleaseRepository({ ...actions, T3CODE_DESKTOP_UPDATE_REPOSITORY: "other/t3code" }), + ).toBe("other/t3code"); + }); + + effectIt.effect("names the same repository as the desktop updater feed for every setting", () => + Effect.gen(function* () { + const cases: Array> = [ + {}, + { T3CODE_DESKTOP_UPDATE_REPOSITORY: "fork-owner/t3code" }, + { GITHUB_REPOSITORY: "fork-owner/t3code" }, + { T3CODE_DESKTOP_UPDATE_REPOSITORY: "a/t3code", GITHUB_REPOSITORY: "b/t3code" }, + { T3CODE_RELEASE_REPOSITORY: "b/t3code", GITHUB_REPOSITORY: "b/t3code" }, + ]; + for (const env of cases) { + const feed = yield* desktopFeedRepository(env); + expect(resolveReleaseRepository(env), JSON.stringify(env)).toBe(feed); + } + }), + ); + + it("fails the build when the former setting disagrees with the desktop updater's", () => { + for (const env of [ + { T3CODE_RELEASE_REPOSITORY: "fork-owner/t3code" }, + { T3CODE_RELEASE_REPOSITORY: "b/t3code", T3CODE_DESKTOP_UPDATE_REPOSITORY: "a/t3code" }, + { T3CODE_RELEASE_REPOSITORY: "b/t3code", GITHUB_REPOSITORY: "a/t3code" }, + ]) { + expect(() => resolveReleaseRepository(env), JSON.stringify(env)).toThrow( + /T3CODE_RELEASE_REPOSITORY=".+" disagrees.*Set T3CODE_DESKTOP_UPDATE_REPOSITORY/, + ); + } + }); + + it("fails the build on a malformed value instead of falling back to upstream", () => { + for (const value of ["owner", "owner/repo/extra", "owner/..", "https://github.com/o/r"]) { + expect(() => resolveReleaseRepository({ T3CODE_DESKTOP_UPDATE_REPOSITORY: value })).toThrow( + /T3CODE_DESKTOP_UPDATE_REPOSITORY/, + ); + } + }); +}); diff --git a/scripts/lib/release-repository.ts b/scripts/lib/release-repository.ts new file mode 100644 index 0000000000000..ee0d22b7f4cee --- /dev/null +++ b/scripts/lib/release-repository.ts @@ -0,0 +1,45 @@ +/** + * The repository a build updates from, for the server, CLI and desktop SSH installs. It is the one + * upstream already uses for the desktop updater feed and release links (docs/operations/release.md): + * T3CODE_DESKTOP_UPDATE_REPOSITORY, else GITHUB_REPOSITORY, else "" for upstream. + */ +export const RELEASE_REPOSITORY_ENV = "T3CODE_DESKTOP_UPDATE_REPOSITORY"; +// The setting's first name in this fork; still accepted only when it names the same repository. +const FORMER_RELEASE_REPOSITORY_ENV = "T3CODE_RELEASE_REPOSITORY"; + +const SEGMENT = /^[A-Za-z0-9._-]+$/u; + +function isValidSegment(value: string | undefined): value is string { + return value !== undefined && value !== "." && value !== ".." && SEGMENT.test(value); +} + +/** The configured repository, or "" for the upstream default. Throws on a malformed value or a + * disagreeing former setting, so no build ships the desktop and the server on different feeds. */ +export function resolveReleaseRepository( + env: Readonly> = process.env, +): string { + // The same order as apps/web/vite.config.ts and resolveGitHubPublishConfig read them. + const configured = env[RELEASE_REPOSITORY_ENV]?.trim() || env.GITHUB_REPOSITORY?.trim() || ""; + const former = env[FORMER_RELEASE_REPOSITORY_ENV]?.trim() || ""; + if (former !== "" && former !== configured) { + throw new Error( + `${FORMER_RELEASE_REPOSITORY_ENV}="${former}" disagrees with the repository the desktop ` + + `updater uses ("${configured || "pingdotgg/t3code"}"). Set ${RELEASE_REPOSITORY_ENV} instead.`, + ); + } + if (configured === "") return ""; + const [owner, name, ...rest] = configured.split("/"); + if (!isValidSegment(owner) || !isValidSegment(name) || rest.length > 0) { + throw new Error( + `${RELEASE_REPOSITORY_ENV} (or GITHUB_REPOSITORY) must be "/", got "${configured}".`, + ); + } + return configured; +} + +/** The bundler `define` entry every build that bundles `@t3tools/shared/cliRelease` carries. */ +export function releaseRepositoryDefine( + env: Readonly> = process.env, +): Record { + return { __T3CODE_RELEASE_REPOSITORY__: JSON.stringify(resolveReleaseRepository(env)) }; +} From ee69d98b50ab72cdfcc7e3be6259ab1d5f4d9ad4 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:36:27 +0100 Subject: [PATCH 02/27] fork: release workflow for v-fork.N on GitHub-hosted runners (#2) * fork: release workflow for v-fork.N on GitHub-hosted runners fork-release.yml builds a fork release from a commit: upstream's tests (fork-tests.yml, the release.yml test jobs on hosted runners), the JS bundle with this repository as the release repository, the Linux x64 server archive and the desktop apps through upstream's release-desktop.yml, unsigned unless signing secrets exist. Publishing is opt-in, makes a normal release marked latest, and stays a draft until every file is uploaded. Co-Authored-By: Claude Opus 5.5 * fork release: guards in a tested script, write token only where it publishes - A version that already has a tag or a published release is refused before the draft is created and again right before it is published, so "Re-run failed jobs" cannot replace a live release's files. - Leftover drafts are removed under pipefail and each removal is logged; a failed release listing fails the step instead of reading as "no drafts". - Only released upstream code: refuses any commit between the base tag and the build that is on upstream's default branch, a base tag that is not the newest stable tag in the history (a stray tag on an old commit), and a base above upstream's latest release. - Marked latest only when above every published fork release. - The CLI archives (the `t3` executable bundles the server again) must name this repository, not only apps/server/dist. - Actions pinned by commit; fail_on_unmatched_files; named signing secrets instead of secrets: inherit; persist-credentials: false. Files are collected in a read-only assemble job; the publish job holds the write token, runs no package code, and only its three release steps see the token. - The workflow's `published` output used jobs.publish.result, which workflow_call outputs cannot read; it now comes from the publish step. Tests: .github/scripts/fork-release.test.sh (local git repos, fake gh; also run in preflight) and fork-release-workflow.test.py (workflow shape). Co-Authored-By: Claude Opus 5.5 * fork release: check only the server program, never publish below a published release - check-archives reads only /t3 (t3.exe in the zip), not client/, and fails when the program is missing. The web client names this repository on every Actions build, so it passed while the server still named upstream. - check-bundle (new) checks apps/server/dist without dist/client, for the same reason. - resolve and publish refuse a version that is not above every published release on the server's stable channel: the server updater takes the newest-created release, so an older build published later would read as a downgrade. Published releases are always latest. - Preflight runs fork-release-workflow.test.py too. - The build sets T3CODE_DESKTOP_UPDATE_REPOSITORY (the name release-repository.ts reads); the former T3CODE_RELEASE_REPOSITORY is gone. The publish job takes its guard script from github.workflow_sha, not from the commit being built. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: t3code-fork Co-authored-by: Claude Opus 5.5 --- .github/scripts/fork-release-workflow.test.py | 142 +++++ .github/scripts/fork-release.sh | 233 ++++++++ .github/scripts/fork-release.test.sh | 294 ++++++++++ .github/workflows/fork-release.yml | 530 ++++++++++++++++++ .github/workflows/fork-tests.yml | 132 +++++ 5 files changed, 1331 insertions(+) create mode 100644 .github/scripts/fork-release-workflow.test.py create mode 100755 .github/scripts/fork-release.sh create mode 100755 .github/scripts/fork-release.test.sh create mode 100644 .github/workflows/fork-release.yml create mode 100644 .github/workflows/fork-tests.yml diff --git a/.github/scripts/fork-release-workflow.test.py b/.github/scripts/fork-release-workflow.test.py new file mode 100644 index 0000000000000..2ee5be72787cc --- /dev/null +++ b/.github/scripts/fork-release-workflow.test.py @@ -0,0 +1,142 @@ +"""Checks the shape of .github/workflows/fork-release.yml that its guards rely on. + + python3 .github/scripts/fork-release-workflow.test.py [path/to/fork-release.yml] + +Needs PyYAML. Each check prints ok/FAIL; any FAIL exits 1. +""" + +import pathlib +import re +import sys + +import yaml + +root = pathlib.Path(__file__).resolve().parents[2] +path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else root / ".github/workflows/fork-release.yml" +text = path.read_text() +wf = yaml.safe_load(text) +desktop = yaml.safe_load((root / ".github/workflows/release-desktop.yml").read_text()) +jobs = wf["jobs"] +failures = [] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name + ("" if ok or not detail else f"\n | {detail}")) + if not ok: + failures.append(name) + + +def steps(job): + return job.get("steps", []) + + +def all_steps(): + for job_id, job in jobs.items(): + for step in steps(job): + yield job_id, step + + +# Actions run by commit, so a moved tag cannot change what runs (the release action holds a write +# token). Local actions and workflows are part of this repository. +unpinned = [] +for line in text.splitlines(): + m = re.match(r"\s*(?:-\s*)?uses:\s*(\S+)(.*)$", line) + if not m or m.group(1).startswith("./"): + continue + if not re.fullmatch(r"[\w.-]+/[\w./-]+@[0-9a-f]{40}", m.group(1)) or not re.match(r"\s*#\s*v\d", m.group(2)): + unpinned.append(m.group(1)) +check("external actions are pinned by commit SHA with a version comment", not unpinned, ", ".join(unpinned)) + +# Named secrets only: the signing secrets release-desktop.yml declares, nothing inherited. +declared = set(desktop[True]["workflow_call"]["secrets"]) +inherit = [j for j, job in jobs.items() if job.get("secrets") == "inherit"] +check("no job passes secrets: inherit", not inherit, ", ".join(inherit)) +wrong = [] +for job_id, job in jobs.items(): + if job.get("uses") == "./.github/workflows/release-desktop.yml": + passed = job.get("secrets") + if not isinstance(passed, dict) or set(passed) != declared or any( + v != "${{ secrets.%s }}" % k for k, v in passed.items() + ): + wrong.append(job_id) +check("release-desktop.yml gets exactly the secrets it declares", not wrong, ", ".join(wrong)) +check("FORK_PUSH_TOKEN appears nowhere", "FORK_PUSH_TOKEN" not in text) + +bad = [j for j, s in all_steps() if str(s.get("uses", "")).startswith("actions/checkout@") + and (s.get("with") or {}).get("persist-credentials") is not False] +check("every checkout sets persist-credentials: false", not bad, ", ".join(bad)) + +release_steps = [(j, s) for j, s in all_steps() if str(s.get("uses", "")).startswith("softprops/action-gh-release@")] +check("one release-action step", len(release_steps) == 1) +check("the release action fails on unmatched files", + all((s.get("with") or {}).get("fail_on_unmatched_files") is True for _, s in release_steps)) + +# The write token: only the publish job has it, and only its three release-writing steps use it. +write_jobs = [j for j, job in jobs.items() if (job.get("permissions") or {}).get("contents") == "write"] +check("only the publish job has contents: write", write_jobs == ["publish"], ", ".join(write_jobs)) +package_code = re.compile(r"(^|[\s;&|(])(vp|node|npm|npx|pnpm|bun)\s") +for job_id in write_jobs: + job_steps = steps(jobs[job_id]) + runs_packages = [s.get("name") for s in job_steps + if str(s.get("uses", "")).startswith("./") or package_code.search(str(s.get("run", "")))] + check(f"{job_id}: runs no package code", not runs_packages, ", ".join(map(str, runs_packages))) + token_steps = [s for s in job_steps if "github.token" in yaml.safe_dump(s) or "secrets." in yaml.safe_dump(s) + or str(s.get("uses", "")).startswith("softprops/")] + allowed = [s for s in token_steps if str(s.get("uses", "")).startswith("softprops/") + or re.fullmatch(r"bash \.github/scripts/fork-release\.sh (remove-drafts|publish) .*", str(s.get("run", "")).strip())] + check(f"{job_id}: the token reaches only the remove-drafts, create and publish steps", + len(token_steps) == len(allowed) == 3, ", ".join(str(s.get("name")) for s in token_steps)) + + def index(pred): + return next((i for i, s in enumerate(job_steps) if pred(s)), -1) + + remove = index(lambda s: "fork-release.sh remove-drafts" in str(s.get("run", ""))) + create = index(lambda s: str(s.get("uses", "")).startswith("softprops/")) + publish = index(lambda s: "fork-release.sh publish" in str(s.get("run", ""))) + check(f"{job_id}: refuse/remove drafts, then create the draft, then publish it", + -1 < remove < create < publish) + if create >= 0 and publish >= 0: + check(f"{job_id}: publishes the draft it created, by id", + job_steps[create].get("id") == "draft" + and "steps.draft.outputs.id" in yaml.safe_dump(job_steps[publish])) + +# Shell steps fail on any error in a pipeline. +lax = [] +for job_id, s in all_steps(): + run = s.get("run") + if run is None: + continue + lines = [l for l in run.strip().splitlines() if l.strip() and not l.strip().startswith("#")] + if s.get("shell") != "bash" or (len(lines) > 1 and lines[0].strip() != "set -euo pipefail"): + lax.append(f"{job_id}/{s.get('name')}") +check("run steps use bash and multi-line ones start with set -euo pipefail", not lax, "; ".join(lax)) + +runs = "\n".join(str(s.get("run", "")) for _, s in all_steps()) +check("preflight resolves the version through the guard script", + "fork-release.sh resolve" in "\n".join(str(s.get("run", "")) for s in steps(jobs["preflight"]))) +check("preflight runs the guard tests", "fork-release.test.sh" in runs) +check("the CLI archives are checked for the release repository", "fork-release.sh check-archives release-assets" in runs) +check("preflight runs this shape test", "fork-release-workflow.test.py" in + "\n".join(str(s.get("run", "")) for s in steps(jobs["preflight"]))) + +# The server bundle check skips dist/client, which names this repository on every Actions build. +bundle_steps = [s for s in steps(jobs["build_bundle"]) if "apps/server/dist" in str(s.get("run", ""))] +check("the server bundle is checked through check-bundle (without dist/client)", + [str(s.get("run", "")).strip() for s in bundle_steps] + == ["bash .github/scripts/fork-release.sh check-bundle apps/server/dist"]) + +# The build names this fork through the setting scripts/lib/release-repository.ts reads. +build = next((s for s in steps(jobs["build_bundle"]) if s.get("name") == "Build JS bundle"), {}) +check("the build sets T3CODE_DESKTOP_UPDATE_REPOSITORY to this repository", + (build.get("env") or {}).get("T3CODE_DESKTOP_UPDATE_REPOSITORY") == "${{ github.repository }}") +check("the former name T3CODE_RELEASE_REPOSITORY appears nowhere", "T3CODE_RELEASE_REPOSITORY" not in text) + +# The write job's guard script comes from the workflow's own commit, not the commit being built. +guard = [s for s in steps(jobs["publish"]) if str(s.get("uses", "")).startswith("actions/checkout@")] +check("publish checks out its guard script at github.workflow_sha", + len(guard) == 1 and (guard[0].get("with") or {}).get("ref") == "${{ github.workflow_sha }}") + +if failures: + print(f"{len(failures)} failed") + sys.exit(1) +print("all passed") diff --git a/.github/scripts/fork-release.sh b/.github/scripts/fork-release.sh new file mode 100755 index 0000000000000..62e62a4816736 --- /dev/null +++ b/.github/scripts/fork-release.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# The guards of fork-release.yml, one subcommand each, so they can be tested without a run +# (fork-release.test.sh). Every refusal exits non-zero with a ::error:: line; nothing falls back. +# +# resolve Preflight: picks base tag, version and tag for HEAD (key=value lines). +# remove-drafts TAG Refuses a published TAG, then deletes drafts with TAG left by earlier runs. +# publish ID TAG VERSION Refuses a published TAG or a published release that is not below +# VERSION, then publishes draft ID as the latest release. +# check-bundle DIR The server bundle DIR (apps/server/dist) outside DIR/client must name +# REPOSITORY as its release repository. +# check-archives DIR The `t3` program of every CLI archive in DIR must name REPOSITORY. +# +# Environment: REPOSITORY (owner/name of this fork; GITHUB_REPOSITORY in a run), UPSTREAM +# (owner/name, resolve looks it up), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN +# (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout. +set -euo pipefail + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +ORIGIN="${ORIGIN:-origin}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +stable_version_re='^v[0-9]+\.[0-9]+\.[0-9]+$' +fork_version_re='^v?[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' + +# version_gt A B: A sorts above B. Takes X.Y.Z or X.Y.Z-fork.N (leading v allowed). A fork +# release of X.Y.Z sorts above X.Y.Z itself here, which is what this fork's ordering needs. +version_gt() { + local a="${1#v}" b="${2#v}" i + local -a x y + IFS=. read -r -a x <<< "${a/-fork./.}" + IFS=. read -r -a y <<< "${b/-fork./.}" + for i in 0 1 2 3; do + if (( 10#${x[i]:-0} != 10#${y[i]:-0} )); then + (( 10#${x[i]:-0} > 10#${y[i]:-0} )) + return + fi + done + return 1 +} + +# Prints the releases of this fork through a jq filter; a failed listing stops everything. +releases() { + gh api "repos/$REPOSITORY/releases" --paginate --jq "$1" \ + || die "Could not list the releases of $REPOSITORY; nothing was changed." +} + +# A published (non-draft) release or a tag with this name means the version is taken: "Re-run failed +# jobs" reuses the preflight's version, and publishing again would replace a live release's files. +refuse_published() { + local tag="$1" published + if git ls-remote --exit-code --tags "$ORIGIN" "refs/tags/$tag" > /dev/null; then + die "Tag $tag already exists, so $tag is published or being published. Start a new run (Re-run all jobs) for the next number; nothing was changed." + else + # 2 means no such ref; anything else is a failed lookup, which must not read as "free". + [[ $? == 2 ]] || die "Could not list the tags of $ORIGIN." + fi + published="$(releases ".[] | select((.draft | not) and .tag_name == \"$tag\") | .id")" + if [[ -n "$published" ]]; then + die "A published release ($published) already has tag $tag. Start a new run (Re-run all jobs); nothing was changed." + fi +} + +# The server's updater (packages/shared/src/cliRelease.ts newestCliReleaseVersion) takes the +# newest-created non-draft release on its channel, not the highest version. So every new release +# must be above every published one, or `t3 update` would see a downgrade and stall until a higher +# build exists. Stable channel = any vX.Y.Z[-...] tag except -nightly./-preview. builds. +refuse_older() { + local version="$1" other published + published="$(releases '.[] | select(.draft | not) | .tag_name')" + for other in $published; do + [[ "$other" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]] || continue + [[ ! "$other" =~ -(nightly|preview)\. ]] || continue + [[ "$other" =~ $stable_version_re || "$other" =~ $fork_version_re ]] \ + || die "Published release $other is on the server's stable channel but has no X.Y.Z or X.Y.Z-fork.N version, so $version cannot be ordered against it; nothing was changed." + version_gt "$version" "$other" \ + || die "$other is published and not below $version. The server updates to the newest-created release, so publishing $version would show every server a downgrade. Build a newer upstream release instead; to go back, install the older release with t3 update --version X --allow-downgrade. Nothing was changed." + done +} + +cmd_resolve() { + local upstream="${UPSTREAM:-}" + if [[ -z "$upstream" ]]; then + upstream="$(gh api "repos/$REPOSITORY" --jq '.parent.full_name // empty')" + [[ -n "$upstream" ]] || die "$REPOSITORY is not a fork, so there is no upstream release to build on." + fi + local url="${UPSTREAM_URL:-https://github.com/$upstream.git}" + local head + head="$(git rev-parse HEAD)" + + # Read-only fetch of upstream's tags and default branch into refs this run alone uses. + git fetch --quiet --no-tags "$url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" + + # Lists are read into variables first: a failure inside <(...) would go unnoticed. + local refs tag + local -a contained=() + refs="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags)" + for tag in $(grep -E "$stable_version_re" <<< "$refs" | sort -rV || true); do + if git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" HEAD; then + contained+=("$tag") + fi + done + (( ${#contained[@]} > 0 )) || die "$head contains no stable $upstream release tag." + local base_tag="${contained[0]}" + local base + base="$(git rev-parse "refs/upstream-tags/$base_tag^{commit}")" + + # The base must be the newest stable tag in the series' history, not just the highest name: a + # stray tag on an old commit (say v1.0.0) would otherwise outrank every later real release. + for tag in "${contained[@]:1}"; do + git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" "$base" \ + || die "$base_tag sorts highest but is not the newest stable $upstream tag in this history ($tag is not part of it). Delete or fix the stray tag; nothing was built." + done + + # Upstream's own ordering: never above the release upstream marks latest. + local upstream_latest + upstream_latest="$(gh api "repos/$upstream/releases/latest" --jq '.tag_name')" + [[ "$upstream_latest" =~ $stable_version_re ]] \ + || die "$upstream's latest release is '$upstream_latest', not a stable vX.Y.Z tag." + if version_gt "$base_tag" "$upstream_latest"; then + die "$base_tag sorts above $upstream's latest release $upstream_latest. Only released upstream versions are built." + fi + + # Releases only: every commit after the base tag must be the fork's own. Upstream commits there + # are unreleased upstream code (for example a branch that still equals upstream's default branch). + local total own + total="$(git rev-list --count "$base..HEAD")" + own="$(git rev-list --count "$base..HEAD" --not refs/upstream-head)" + if [[ "$total" != "$own" ]]; then + die "$head holds $((total - own)) unreleased $upstream commits after $base_tag. Build a branch of $base_tag plus the fork's own commits." + fi + + local version="${base_tag#v}" last + last="$(git ls-remote --tags --refs "$ORIGIN" "v$version-fork.*" \ + | sed -n "s|.*refs/tags/v$version-fork\.\([0-9]\+\)$|\1|p" | sort -n | tail -n1)" + version="$version-fork.$(( ${last:-0} + 1 ))" + refuse_older "$version" + { + echo "ref=$head" + echo "upstream=$upstream" + echo "base_tag=$base_tag" + echo "version=$version" + echo "tag=v$version" + } >> "$OUTPUT" + echo "Building $version from $(git rev-parse --short HEAD) on $upstream $base_tag." \ + | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" >&2 +} + +cmd_remove_drafts() { + local tag="${1:?tag}" id + refuse_published "$tag" + local ids + ids="$(releases ".[] | select(.draft and .tag_name == \"$tag\") | .id")" + for id in $ids; do + gh api -X DELETE "repos/$REPOSITORY/releases/$id" > /dev/null + echo "Removed leftover draft $id for $tag." + done + [[ -n "$ids" ]] || echo "No leftover draft for $tag." +} + +cmd_publish() { + local id="${1:?release id}" tag="${2:?tag}" version="${3:?version}" + [[ "$version" =~ $fork_version_re ]] || die "'$version' is not a fork version." + refuse_published "$tag" + # Checked again here: a release published since preflight must not end up above this one. After + # this check VERSION is the highest release, so the server and the desktop updater agree on it. + refuse_older "$version" + gh api -X PATCH "repos/$REPOSITORY/releases/$id" -F draft=false -f make_latest=true > /dev/null + echo "Published $tag (latest: true)." + { + echo "published=true" + echo "latest=true" + } >> "$OUTPUT" +} + +# The web client (DIR/client) names this repository on every Actions build anyway: upstream's +# apps/web/vite.config.ts bakes GITHUB_REPOSITORY into it. Only the server code counts here. +cmd_check_bundle() { + local dir="${1:?directory}" file files + [[ -d "$dir" ]] || die "No server bundle at $dir." + files="$(find "$dir" -path "$dir/client" -prune -o -type f -print)" + [[ -n "$files" ]] || die "The server bundle $dir holds no files outside client/." + while IFS= read -r file; do + if grep -qaF "\"$REPOSITORY\"" "$file"; then + echo "The server bundle names $REPOSITORY (${file#"$dir"/})." + return 0 + fi + done <<< "$files" + die "The server bundle $dir (outside client/) does not name $REPOSITORY as its release repository, so it would update from elsewhere." +} + +cmd_check_archives() { + local dir="${1:?directory}" archive work name stem program found=0 + shopt -s nullglob + for archive in "$dir"/t3-*.tar.gz "$dir"/t3-*.zip; do + found=1 + work="$(mktemp -d)" + case "$archive" in + *.tar.gz) tar -xzf "$archive" -C "$work" ;; + *.zip) python3 -I -m zipfile -e "$archive" "$work" ;; + esac + # Only the `t3` program at / (scripts/build-cli-archive.ts): it bundles the server again, + # and its updater is what counts. client/ names this repository on every Actions build anyway. + name="$(basename "$archive")" + stem="${name%.tar.gz}" + stem="${stem%.zip}" + program="$work/$stem/t3" + [[ "$archive" != *.zip ]] || program="$work/$stem/t3.exe" + [[ -f "$program" ]] \ + || die "$name has no ${program#"$work"/}, so the server program in it cannot be checked." + grep -qaF "\"$REPOSITORY\"" "$program" \ + || die "${program#"$work"/} in $name does not name $REPOSITORY as its release repository, so it would update from elsewhere." + rm -rf "$work" + echo "${program#"$work"/} in $name names $REPOSITORY." + done + (( found )) || die "No CLI archive in $dir." +} + +command="${1:-}" +shift || true +case "$command" in + resolve) cmd_resolve "$@" ;; + remove-drafts) cmd_remove_drafts "$@" ;; + publish) cmd_publish "$@" ;; + check-bundle) cmd_check_bundle "$@" ;; + check-archives) cmd_check_archives "$@" ;; + version-gt) version_gt "$@" ;; + *) die "usage: $0 resolve | remove-drafts TAG | publish ID TAG VERSION | check-bundle DIR | check-archives DIR" ;; +esac diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh new file mode 100755 index 0000000000000..2a1e2240ecba1 --- /dev/null +++ b/.github/scripts/fork-release.test.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Tests for fork-release.sh against local git repositories and a fake `gh`; no network. +# bash .github/scripts/fork-release.test.sh (FORK_RELEASE_SH picks another script) +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$(realpath "${FORK_RELEASE_SH:-$here/fork-release.sh}")" +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the workflow uses. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN + +# Fake gh: `gh api [-X M] PATH [--paginate] [--jq EXPR] [-f/-F k=v]...` served from $root/api. +mkdir -p "$root/bin" "$root/api" +cat > "$root/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +api="$FAKE_API" +echo "gh $*" >> "$api/calls" +[[ "$1" == api ]] || { echo "fake gh: only api" >&2; exit 2; } +shift +method=GET path="" jq="" fields=() +while (( $# )); do + case "$1" in + -X) method="$2"; shift 2 ;; + --jq) jq="$2"; shift 2 ;; + --paginate) shift ;; + -f|-F) fields+=("$2"); shift 2 ;; + *) path="$1"; shift ;; + esac +done +case "$method $path" in + "GET repos/me/t3code") body='{"parent":{"full_name":"up/t3code"}}' ;; + "GET repos/up/t3code/releases/latest") body="{\"tag_name\":\"$(cat "$api/upstream_latest")\"}" ;; + "GET repos/me/t3code/releases") + [[ ! -e "$api/fail_list" ]] || { echo "HTTP 502: Bad Gateway" >&2; exit 1; } + body="$(cat "$api/releases.json")" ;; + "DELETE repos/me/t3code/releases/"*) + id="${path##*/}" + jq --argjson id "$id" 'map(select(.id != $id))' "$api/releases.json" > "$api/r.tmp" + mv "$api/r.tmp" "$api/releases.json" + echo "deleted $id" >> "$api/writes"; exit 0 ;; + "PATCH repos/me/t3code/releases/"*) + echo "patched ${path##*/} ${fields[*]}" >> "$api/writes"; exit 0 ;; + *) echo "fake gh: no route for $method $path" >&2; exit 1 ;; +esac +if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi +GH +chmod +x "$root/bin/gh" +export PATH="$root/bin:$PATH" FAKE_API="$root/api" + +failures=0 +pass() { echo "ok $1"; } +fail() { + echo "FAIL $1" + sed 's/^/ | /' "$root/out" 2> /dev/null || true + failures=$((failures + 1)) +} +run() { (cd "$1" && shift && "$script" "$@") > "$root/out" 2>&1; } + +reset_api() { + rm -f "$root/api"/* + echo '[]' > "$root/api/releases.json" + echo "${1:-v0.1.9}" > "$root/api/upstream_latest" +} + +# Upstream history: u1 (v0.1.8) - u2 (v0.1.9) - u3 (unreleased, upstream main). +new_upstream() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + git commit -q --allow-empty -m u1 && git tag v0.1.8 + git commit -q --allow-empty -m u2 && git tag -a -m v0.1.9 v0.1.9 + git commit -q --allow-empty -m u3 + git push -q https://github.com/up/t3code.git main --tags + ) +} + +# The fork's checkout: $1 (an upstream rev) plus one fork commit, pushed as the fork's main. +new_fork() { + rm -rf "$root/work" + git clone -q https://github.com/up/t3code.git "$root/work" + ( + cd "$root/work" + git remote set-url origin https://github.com/me/t3code.git + git checkout -q -B main "$1" + [[ "${2:-}" == bare ]] || git commit -q --allow-empty -m "fork: own change" + git push -q --force origin main + ) +} + +fork_tag() { (cd "$root/work" && git tag "$1" && git push -q origin "refs/tags/$1"); } + +expect_resolved() { + local name="$1" want="$2" + if run "$root/work" resolve && grep -qx "version=$want" "$root/out"; then pass "$name"; else fail "$name"; fi +} +expect_refused() { + local name="$1" pattern="$2" + shift 2 + if ! run "$@" && grep -q "::error::.*$pattern" "$root/out"; then pass "$name"; else fail "$name"; fi +} + +# --- resolve ----------------------------------------------------------------------------------- +new_upstream +reset_api +new_fork v0.1.9 +expect_resolved "resolve: v0.1.9 plus a fork commit is 0.1.9-fork.1" 0.1.9-fork.1 +fork_tag v0.1.9-fork.1 +fork_tag v0.1.9-fork.x +expect_resolved "resolve: the next build counts the fork's numbered tags" 0.1.9-fork.2 + +new_fork main bare +expect_refused "resolve: refuses unreleased upstream commits (upstream main)" "unreleased" "$root/work" resolve +new_fork main +expect_refused "resolve: refuses unreleased upstream commits under fork commits" "unreleased" "$root/work" resolve + +(cd "$root/upwork" && git tag v1.0.0 v0.1.8 && git push -q https://github.com/up/t3code.git v1.0.0) +new_fork v0.1.9 +expect_refused "resolve: refuses a stray higher tag on an old commit" "not the newest" "$root/work" resolve + +new_upstream +reset_api v0.1.9 +(cd "$root/upwork" && git tag v0.1.10 main && git push -q https://github.com/up/t3code.git v0.1.10) +new_fork v0.1.10 +expect_refused "resolve: refuses a base above upstream's latest release" "above up/t3code's latest" "$root/work" resolve +echo v0.1.10 > "$root/api/upstream_latest" +expect_resolved "resolve: accepts it once upstream marks it latest" 0.1.10-fork.1 + +reset_api v0.1.10 +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10-fork.3"}]' > "$root/api/releases.json" +new_fork v0.1.9 +expect_refused "resolve: refuses a build below a published release (older ref after a newer one)" \ + "v0.1.10-fork.3 is published and not below 0.1.9-fork.1" "$root/work" resolve +echo '[{"id":5,"draft":true,"prerelease":false,"tag_name":"v0.1.10-fork.3"}]' > "$root/api/releases.json" +expect_resolved "resolve: a higher draft does not block (updaters skip drafts)" 0.1.9-fork.1 + +# --- remove-drafts ----------------------------------------------------------------------------- +new_upstream +reset_api +new_fork v0.1.9 +echo '[{"id":7,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.1"}, + {"id":8,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.9"}]' > "$root/api/releases.json" +if run "$root/work" remove-drafts v0.1.9-fork.1 && grep -qx "deleted 7" "$root/api/writes" \ + && ! grep -q "deleted 8" "$root/api/writes" && grep -q "Removed leftover draft 7" "$root/out"; then + pass "remove-drafts: deletes only that tag's draft and says so" +else + fail "remove-drafts: deletes only that tag's draft and says so" +fi + +reset_api +touch "$root/api/fail_list" +expect_refused "remove-drafts: a failed release listing fails the step" "Could not list the releases" "$root/work" remove-drafts v0.1.9-fork.1 +[[ ! -e "$root/api/writes" ]] || fail "remove-drafts: nothing written after a failed listing" + +reset_api +fork_tag v0.1.9-fork.1 +expect_refused "remove-drafts: refuses a version whose tag exists (re-run failed jobs)" "already exists" \ + "$root/work" remove-drafts v0.1.9-fork.1 + +reset_api +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +expect_refused "remove-drafts: refuses a version with a published release" "published release" \ + "$root/work" remove-drafts v0.1.9-fork.2 + +# --- publish ----------------------------------------------------------------------------------- +reset_api +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +if run "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 && grep -qx "patched 9 draft=false make_latest=true" "$root/api/writes" \ + && grep -qx "published=true" "$root/out"; then + pass "publish: a version above every release is marked latest" +else + fail "publish: a version above every release is marked latest" +fi + +reset_api +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +expect_refused "publish: refuses a version below a published release (server takes the newest created)" \ + "v0.1.10-fork.1 is published and not below 0.1.9-fork.2" "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 +[[ ! -e "$root/api/writes" ]] || fail "publish: nothing written for an older version" + +reset_api +echo '[{"id":5,"draft":false,"prerelease":true,"tag_name":"v0.1.10-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +expect_refused "publish: a higher prerelease also blocks (the server ignores the prerelease flag)" \ + "not below" "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 + +reset_api +echo '[{"id":4,"draft":false,"prerelease":false,"tag_name":"v0.2.0-nightly.20261101.3"}, + {"id":6,"draft":false,"prerelease":false,"tag_name":"notes"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.2"}]' > "$root/api/releases.json" +if run "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 && grep -qx "patched 9 draft=false make_latest=true" "$root/api/writes"; then + pass "publish: nightly and non-version tags are other channels and do not block" +else + fail "publish: nightly and non-version tags are other channels and do not block" +fi + +reset_api +echo '[{"id":4,"draft":false,"prerelease":false,"tag_name":"v0.1.8-rc.1"}]' > "$root/api/releases.json" +expect_refused "publish: a stable-channel tag it cannot order fails loudly" "cannot be ordered" \ + "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 + +reset_api +echo '[{"id":9,"draft":true,"prerelease":false,"tag_name":"v0.1.9-fork.1"}]' > "$root/api/releases.json" +expect_refused "publish: re-checks the tag right before un-drafting" "already exists" "$root/work" publish 9 v0.1.9-fork.1 0.1.9-fork.1 +[[ ! -e "$root/api/writes" ]] || fail "publish: nothing written when the tag exists" + +reset_api +touch "$root/api/fail_list" +expect_refused "publish: a failed release listing fails the step" "Could not list the releases" "$root/work" publish 9 v0.1.9-fork.2 0.1.9-fork.2 +[[ ! -e "$root/api/writes" ]] || fail "publish: nothing written after a failed listing" + +# --- check-archives ---------------------------------------------------------------------------- +# Layout of scripts/build-cli-archive.ts: /t3 (t3.exe in the Windows zip) plus /client/. +# The web client names this fork on every Actions build (upstream's apps/web/vite.config.ts), so it +# must not count; only the program does. +stage() { # stage DIR STEM PROGRAM PROGRAM_REPO CLIENT_REPO + mkdir -p "$1/$2/client/assets" + printf 'x\0const repo = "%s";\0' "$4" > "$1/$2/$3" + printf 'const u = "%s";' "$5" > "$1/$2/client/assets/index.js" +} +pack() { # pack DIR STEM OUT + case "$3" in + *.tar.gz) tar -czf "$3" -C "$1" "$2" ;; + *.zip) (cd "$1" && python3 -I -m zipfile -c "$3" "$2") ;; + esac +} +lin=t3-0.1.9-fork.1-linux-x64 win=t3-0.1.9-fork.1-win-x64 +mkdir -p "$root/assets-good" "$root/assets-bad" "$root/assets-repro" "$root/assets-noprog" "$root/assets-none" +stage "$root/arch/good" "$lin" t3 me/t3code me/t3code +stage "$root/arch/good" "$win" t3.exe me/t3code me/t3code +pack "$root/arch/good" "$lin" "$root/assets-good/$lin.tar.gz" +pack "$root/arch/good" "$win" "$root/assets-good/$win.zip" +cp "$root/assets-good/$lin.tar.gz" "$root/assets-bad/" +stage "$root/arch/bad" "$win" t3.exe up/t3code up/t3code +pack "$root/arch/bad" "$win" "$root/assets-bad/$win.zip" +# The review's repro: t3 names upstream, the web client names the fork. +stage "$root/arch/repro" "$lin" t3 up/t3code me/t3code +pack "$root/arch/repro" "$lin" "$root/assets-repro/$lin.tar.gz" +# No program at all, but the client names the fork. +mkdir -p "$root/arch/noprog/$lin/client" && printf '"me/t3code"' > "$root/arch/noprog/$lin/client/x.js" +printf '"me/t3code"' > "$root/arch/noprog/$lin/t3.sh" +pack "$root/arch/noprog" "$lin" "$root/assets-noprog/$lin.tar.gz" +if run "$root" check-archives "$root/assets-good"; then pass "check-archives: archives whose program names this fork pass"; else fail "check-archives: archives whose program names this fork pass"; fi +expect_refused "check-archives: an archive naming upstream fails" "$win/t3.exe in $win.zip does not name me/t3code" "$root" check-archives "$root/assets-bad" +expect_refused "check-archives: t3 names upstream, client/ names the fork -> fails" "$lin/t3 in $lin.tar.gz does not name me/t3code" "$root" check-archives "$root/assets-repro" +expect_refused "check-archives: a missing program fails" "has no $lin/t3" "$root" check-archives "$root/assets-noprog" +expect_refused "check-archives: no archive at all fails" "No CLI archive" "$root" check-archives "$root/assets-none" + +# --- check-bundle (apps/server/dist with the web client in dist/client) ------------------------ +bundle() { # bundle NAME SERVER_REPO CLIENT_REPO + mkdir -p "$root/dist-$1/client/assets" + printf 'const repo = "%s";' "$2" > "$root/dist-$1/bin.mjs" + printf 'const u = "%s";' "$3" > "$root/dist-$1/client/assets/index.js" +} +bundle good me/t3code me/t3code +bundle repro up/t3code me/t3code +mkdir -p "$root/dist-clientonly/client" && printf '"me/t3code"' > "$root/dist-clientonly/client/x.js" +if run "$root" check-bundle "$root/dist-good"; then pass "check-bundle: a server bundle naming this fork passes"; else fail "check-bundle: a server bundle naming this fork passes"; fi +expect_refused "check-bundle: server names upstream, client/ names the fork -> fails" "outside client/) does not name me/t3code" "$root" check-bundle "$root/dist-repro" +expect_refused "check-bundle: only client/ files -> fails" "no files outside client/" "$root" check-bundle "$root/dist-clientonly" +expect_refused "check-bundle: a missing bundle fails" "No server bundle" "$root" check-bundle "$root/dist-missing" + +# --- version order ----------------------------------------------------------------------------- +check_gt() { + if (cd "$root" && "$script" version-gt "$1" "$2") 2> /dev/null; then got=gt; else got=not; fi + if [[ "$got" == "$3" ]]; then pass "version-gt $1 $2 is $3"; else fail "version-gt $1 $2 is $3"; fi +} +check_gt 0.1.9-fork.10 0.1.9-fork.9 gt +check_gt v0.1.10-fork.1 v0.1.9-fork.12 gt +check_gt 0.1.9-fork.2 0.1.9-fork.2 not +check_gt 0.1.9-fork.2 0.2.0-fork.1 not +check_gt v1.0.0 v0.1.10 gt + +if (( failures )); then + echo "$failures failed" + exit 1 +fi +echo "all passed" diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000000..fb7e08d5ae4d1 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,530 @@ +name: Fork release + +# Builds this fork's release `v-fork.`: upstream's tests, the Linux x64 server archive +# with SHA256SUMS, and the desktop apps (Linux x64, Windows x64, macOS arm64 and x64), all on +# GitHub-hosted runners and unsigned unless signing secrets exist. is the newest stable +# upstream tag the commit contains; counts the fork's earlier builds of it. +# +# Builds name this repository as their release repository (scripts/lib/release-repository.ts reads +# T3CODE_DESKTOP_UPDATE_REPOSITORY, else GITHUB_REPOSITORY in Actions), so the server's update and +# the desktop app's updater look only at this fork. Publishing makes a normal (not pre-) release, a +# draft until every file is uploaded, and only above every published release: the server's updater +# takes the newest-created release, so an older one published later would read as a downgrade. +# +# The guards live in .github/scripts/fork-release.sh (tests: fork-release.test.sh and +# fork-release-workflow.test.py, both run in preflight): only released upstream code, never a +# version that is already published or below a published one, no silent API failures. Actions are +# pinned by commit. Only the publish job holds a write token; it runs no package code, and its guard +# script comes from this workflow's own commit, not from the commit being built. +on: + workflow_dispatch: + inputs: + ref: + description: Commit or branch to build + required: false + default: main + type: string + publish: + description: Publish the release (off builds and keeps the files on the run only) + required: false + default: false + type: boolean + workflow_call: + inputs: + ref: + required: true + type: string + publish: + required: false + default: false + type: boolean + # Only the signing secrets release-desktop.yml declares; a caller passes these, never others. + secrets: + CSC_LINK: + required: false + CSC_KEY_PASSWORD: + required: false + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false + outputs: + version: + value: ${{ jobs.preflight.outputs.version }} + tag: + value: ${{ jobs.preflight.outputs.tag }} + published: + value: ${{ jobs.publish.outputs.published == 'true' }} + +concurrency: + group: fork-release + cancel-in-progress: false + +permissions: + # The Windows desktop job reads this run's artifact list to wait for the Linux CLI archive. + actions: read + contents: read + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + ref: ${{ steps.meta.outputs.ref }} + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + base_tag: ${{ steps.meta.outputs.base_tag }} + upstream: ${{ steps.meta.outputs.upstream }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Test the release guards + shell: bash + run: bash .github/scripts/fork-release.test.sh + + - name: Test the workflow's shape + shell: bash + run: | + set -euo pipefail + python3 -c 'import yaml' 2> /dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + python3 .github/scripts/fork-release-workflow.test.py + + # Refuses unreleased upstream commits, a stray upstream tag and a base above upstream's latest. + - id: meta + name: Resolve fork release version + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/fork-release.sh resolve + + tests: + name: Tests + needs: [preflight] + uses: ./.github/workflows/fork-tests.yml + with: + ref: ${{ needs.preflight.outputs.ref }} + + build_bundle: + name: Build JS bundle + needs: [preflight] + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + persist-credentials: false + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... + + - name: Align package versions to release version + shell: bash + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: node scripts/update-release-package-versions.ts "$VERSION" + + - name: Check bundle-only build support + id: bundle_build + shell: bash + run: | + set -euo pipefail + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + + - name: Build JS bundle + shell: bash + env: + T3CODE_TELEMETRY_CHANNEL: dev + T3CODE_DESKTOP_UPDATE_REPOSITORY: ${{ github.repository }} + run: vp run ${{ steps.bundle_build.outputs.script }} + + # A build that still names upstream would update a fork install to upstream's releases. This + # is the server bundle without dist/client (the web client names this repository on every + # Actions build); the assemble job checks the `t3` program in each CLI archive. + - name: Check the server bundle names this repository + shell: bash + run: bash .github/scripts/fork-release.sh check-bundle apps/server/dist + + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron + if-no-files-found: error + retention-days: 3 + + desktop_linux_x64: + name: Desktop Linux x64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: Linux x64 + runner: ubuntu-latest + platform: linux + target: AppImage + arch: x64 + rust_target: x86_64-unknown-linux-gnu + resource_key: linux-x64 + cli_archive: true + launch_check: true + + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: Windows x64 + runner: ubuntu-latest + verify_runner: windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: true + launch_check: false + + desktop_mac_arm64: + name: Desktop macOS arm64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: macOS arm64 + runner: ${{ vars.FORK_MACOS_RUNNER || 'macos-26' }} + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + launch_check: true + + desktop_mac_x64: + name: Desktop macOS x64 + needs: [preflight, build_bundle] + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: stable + relay_client_tracing: false + clerk_publishable_key: "" + clerk_jwt_template: "" + clerk_cli_oauth_client_id: "" + relay_url: "" + label: macOS x64 + runner: ${{ vars.FORK_MACOS_RUNNER || 'macos-26' }} + platform: mac + target: dmg + arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 + cli_archive: false + launch_check: false + + # Collects and checks every release file with a read-only token; package code runs here only. + assemble: + name: Assemble release files + needs: + [preflight, tests, desktop_linux_x64, desktop_win_x64, desktop_mac_arm64, desktop_mac_x64] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/scripts... + + - name: Download desktop builds + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-* + merge-multiple: true + path: release-assets + + - name: Download CLI archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cli-* + merge-multiple: true + path: release-assets + + - name: Write CLI archive checksums + shell: bash + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + cd release-assets + shopt -s nullglob + archives=(t3-*.tar.gz t3-*.zip) + if [[ ! -f "t3-$VERSION-linux-x64.tar.gz" ]]; then + echo "::error::The Linux x64 server archive is missing." >&2 + exit 1 + fi + sha256sum "${archives[@]}" > SHA256SUMS + cat SHA256SUMS + + # The `t3` executable in each archive bundles the server again (release-desktop.yml build-exe). + - name: Check the CLI archives name this repository + shell: bash + run: bash .github/scripts/fork-release.sh check-archives release-assets + + - name: Merge macOS updater manifests + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + for x64_manifest in release-assets/*-mac-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" + rm -f "$x64_manifest" + fi + done + + - name: Name the Windows updater manifest + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + for x64_manifest in release-assets/*-win-x64.yml; do + mv "$x64_manifest" "${x64_manifest%-win-x64.yml}.yml" + done + + # The desktop app on each platform reads one of these from the latest release. + - name: Check updater manifests + shell: bash + run: | + set -euo pipefail + for manifest in latest.yml latest-mac.yml latest-linux.yml; do + if [[ ! -f "release-assets/$manifest" ]]; then + echo "::error::release-assets/$manifest is missing, so that platform's desktop app would find no update." + exit 1 + fi + done + + - name: Write release notes + shell: bash + env: + UPSTREAM: ${{ needs.preflight.outputs.upstream }} + BASE_TAG: ${{ needs.preflight.outputs.base_tag }} + REF: ${{ needs.preflight.outputs.ref }} + run: | + set -euo pipefail + git fetch --no-tags "https://github.com/$UPSTREAM.git" "refs/tags/$BASE_TAG:refs/upstream/$BASE_TAG" + { + echo "Upstream [$BASE_TAG](https://github.com/$UPSTREAM/releases/tag/$BASE_TAG) with this fork's changes on top:" + echo + git log --reverse --format='- %s (%h)' "refs/upstream/$BASE_TAG..$REF" + } > release-assets/release-notes.md + cat release-assets/release-notes.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload release files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-files + path: release-assets + if-no-files-found: error + retention-days: 7 + + # The only job with a write token: it checks out just the guard script, from this workflow's own + # commit (a caller's commit for workflow_call), and runs no package code. + publish: + name: Publish release + needs: [preflight, assemble] + if: ${{ inputs.publish }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + outputs: + published: ${{ steps.publish.outputs.published }} + steps: + - name: Checkout the guard script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: true + + - name: Download release files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-files + path: release-assets + + - name: Move the release notes aside + shell: bash + run: mv release-assets/release-notes.md release-notes.md + + # "Re-run failed jobs" reuses the preflight's version: refuse it once a tag or published + # release has it. Drafts have no tag; one left by a stopped run of this version is removed. + - name: Refuse a published version, remove leftover drafts + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.preflight.outputs.tag }} + run: bash .github/scripts/fork-release.sh remove-drafts "$TAG" + + - name: Create the release as a draft + id: draft + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: T3 Code ${{ needs.preflight.outputs.tag }} + body_path: release-notes.md + draft: true + prerelease: false + fail_on_unmatched_files: true + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + release-assets/*.tar.gz + release-assets/SHA256SUMS + + # Updaters skip drafts, so nobody sees a release until every file above is uploaded. The tag + # and "nothing published is as high" checks run again right before; it is then made latest. + - name: Publish the draft + id: publish + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.draft.outputs.id }} + TAG: ${{ needs.preflight.outputs.tag }} + VERSION: ${{ needs.preflight.outputs.version }} + run: bash .github/scripts/fork-release.sh publish "$RELEASE_ID" "$TAG" "$VERSION" diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml new file mode 100644 index 0000000000000..199b43a462e8b --- /dev/null +++ b/.github/workflows/fork-tests.yml @@ -0,0 +1,132 @@ +name: Fork tests + +# Upstream's release checks and tests (release.yml: quality, test, test_web, test_server) on +# GitHub-hosted runners, so a fork without upstream's runners can run them. Keep the commands in +# step with release.yml; only the runners and time limits differ. +on: + workflow_call: + inputs: + ref: + description: Commit to test + required: true + type: string + workflow_dispatch: + inputs: + ref: + description: Commit or branch to test + required: true + type: string + +permissions: + contents: read + +jobs: + quality: + name: Quality checks + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Check + run: vp check + + - name: Typecheck + run: vp run typecheck + + test: + name: Package tests + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential + + - name: Test + run: vp run --parallel --concurrency-limit 2 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + + test_web: + name: Web tests + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Install Chromium for web browser tests + run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + + - name: Test + run: vp run --filter @t3tools/web test + + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + + test_server: + name: Server tests ${{ matrix.shard }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + # Eight shards share one install; one saves the store. + save-pnpm-store: ${{ matrix.shard == 1 }} + + - name: Install Chromium for server browser integration tests + run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + + - name: Test + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} From 82bebcb7f206acfddee9444e9099f8443e2483f6 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:04:10 +0100 Subject: [PATCH 03/27] fork: fast CI on GitHub-hosted runners (fork-tests.yml) (#6) * fork: fast CI on GitHub-hosted runners (fork-tests.yml) Upstream's workflows are disabled in the fork, so fork pull requests ran no checks. fork-tests.yml now runs upstream ci.yml's checks on pull_request and push to main: lint (knip, generated GraphQL, vp check), build + typecheck + desktop launch + built web boot, package tests, web tests, 8 server shards, Rust, macOS mobile lint and release smoke. It keeps the workflow_call / workflow_dispatch `ref` interface, so a release run tests one commit with every Linux job and no path filter. Upstream's composite actions and change detector are used as they are. A new `drift` job fails, naming each job and step, when upstream's ci.yml or release.yml test jobs change and the copy did not follow. The Check job lists every job and the reason for every skip. Co-Authored-By: Claude Opus 5.5 * Fork CI: one fork-ci-result check, secret scan, actionlint, faster tests - fork-ci-result needs every job and fails on a skip without a reason; its summary lists each job, why it skipped, and job wall times. - Secret scan (gitleaks, checksum-pinned) of the commits a pull request or push adds; workflow lint (actionlint) next to the drift check. - Ubuntu 24.04 runners as upstream: ubuntu-latest (26.04) fails an apps/server test with its newer git. - Test is split across two runners by test file count; vp's task cache is kept between runs for the typecheck and the server shards. Co-Authored-By: Claude Opus 5.5 * Fork CI: slice large test runs with vitest --shard; lint fixes - Test and Test Web run on two runners each. A package with 100+ test files (and Test Web's 548) runs a vitest --shard slice on every runner; the rest are placed whole by test file count. - Script header comments fit upstream's 4-line comment rule; the jq program in fork-ci-result passes shellcheck. - The Ubuntu 24.04 pin is about choosing OS upgrades, not git: the 24.04 image now ships git 2.56 as well. Co-Authored-By: Claude Opus 5.5 * Fork CI: run every fork script test, checked against a list The workflows job runs each .github/scripts/fork-*.test.{sh,py,cjs} and checks them against .github/fork-ci-tests.txt. A present test that is not listed fails. A listed test that is missing fails once it has been in the tested commit's history; before that it is a note in the job summary, so a test whose pull request has not merged yet does not fail the run. Co-Authored-By: Claude Opus 5.5 * Fork CI: give fork-ci-result actions: read for the job wall times The job list API needs it; a failed call now warns in the log instead of failing silently. Co-Authored-By: Claude Opus 5.5 * scripts: move release-repository.test.ts out of scripts/lib apps/desktop's typecheck includes scripts/lib, and this test imports scripts/build-desktop-artifact.ts, which that project does not list (TS6307). In scripts/ the scripts project covers both files, and the scripts package still runs the test. Co-Authored-By: Claude Opus 5.5 * scripts: fix release-repository.test.ts imports after the move Co-Authored-By: Claude Opus 5.5 * fork-tests: fix round 1 (secret scan range, every test run, one commit per run, drift) Secret scan (.github/scripts/fork-secret-scan.sh, tests: fork-secret-scan.test.sh) - Pull requests scan base.sha..head.sha on a full blob-less clone instead of the newest N commits by date. After a merge of main, the PR's oldest commits fell outside --max-count and were never scanned; a squash merge never brings them to main, yet they stay public under refs/pull/N/head. - Merge commits are diffed with --remerge-diff. Plain `git log -p` shows merges with no diff, so a key added only in a merge commit was never read. - Push: before..after; a force push scans what the new tip has and the old one had not. A new branch, or an old tip that cannot be fetched, scans only the pushed commit and says so in a warning. - The Secret scan job runs the test against the pinned gitleaks before scanning. Every test file under .github/scripts runs somewhere (fork-ci-tests.cjs) - Finds *.test.* in subdirectories too. Runs .sh, .py, .cjs, .mjs and .js; a listed test with another extension fails instead of being skipped. - Upstream's tests are listed as " run by "; the file must name the test and cannot be a disabled upstream workflow. thread-transfer-report.test.cjs was run nowhere (upstream runs it from a workflow_run of its disabled CI); Test 1 runs it now with upstream's other release-check tests. One commit per release run - `changes` resolves a workflow_dispatch/workflow_call ref to a SHA once; every job checks that out, so a branch that moves mid-run cannot split a run over two commits. workflows, lint and the server shards now wait for `changes`. Upstream drift (fork-tests-drift.cjs) - Also digests the top-level env, defaults, concurrency and permissions. - --record refuses (exit 1) while a tracked job is missing instead of saving null, and an old null entry is reported instead of crashing. - --record prints the upstream changes it accepts; the commit and PR must say what was ported for each. Re-recorded .github/fork-tests-upstream.json. It accepts: - ci.yml top-level permissions (contents: read): fork-tests.yml has the same. - ci.yml top-level concurrency (group per PR/sha, cancel on pull_request): fork-tests.yml has the equivalent, with the event name in the group. - release.yml top-level concurrency and permissions: they apply to upstream's release run, not its test jobs. fork-release.yml has its own. No job or step changed upstream. Co-Authored-By: Claude Opus 5.5 * fork-tests: fix round 2 (secret scan reads every commit offline, Lint) Secret scan (.github/scripts/fork-secret-scan.sh, tests: fork-secret-scan.test.sh) - The checkout is blob-less, so gitleaks fetched file contents on demand. gitleaks 8.30.1 stops at the first git error (a failed content fetch), logs "0 commits scanned" and exits 0, and the summary said "No secrets found". The script now runs gitleaks' own `git log -p -U0 ` first: it fetches every blob the scan reads and fails the job (exit 2) when a fetch fails. gitleaks then runs with GIT_NO_LAZY_FETCH=1. - The job also fails when gitleaks scanned fewer commits than that git log has commits with a hunk in a file they do not delete (what gitleaks counts; a pure rename or a clean merge has none, so it can be below rev-list --count). - New tests: contents unreachable after the clone, gitleaks stopping early, and a base or head commit that cannot be fetched. Lint (vp check) - Formatting of fork-ci-tests.cjs, fork-ci-tests.test.cjs, fork-tests-drift.test.cjs. - The header comments of fork-ci-tests.cjs and fork-tests-drift.cjs cut to 4 lines (t3code/no-low-value-comments). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: t3code-fork Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 26 + .github/fork-tests-upstream.json | 482 ++++++++++ .github/scripts/fork-ci-tests.cjs | 172 ++++ .github/scripts/fork-ci-tests.test.cjs | 98 ++ .github/scripts/fork-secret-scan.sh | 123 +++ .github/scripts/fork-secret-scan.test.sh | 194 ++++ .github/scripts/fork-test-shards.cjs | 85 ++ .github/scripts/fork-test-shards.test.cjs | 38 + .github/scripts/fork-tests-drift.cjs | 244 +++++ .github/scripts/fork-tests-drift.test.cjs | 121 +++ .github/workflows/fork-tests.yml | 900 ++++++++++++++++++- scripts/{lib => }/release-repository.test.ts | 4 +- 12 files changed, 2442 insertions(+), 45 deletions(-) create mode 100644 .github/fork-ci-tests.txt create mode 100644 .github/fork-tests-upstream.json create mode 100644 .github/scripts/fork-ci-tests.cjs create mode 100644 .github/scripts/fork-ci-tests.test.cjs create mode 100755 .github/scripts/fork-secret-scan.sh create mode 100755 .github/scripts/fork-secret-scan.test.sh create mode 100644 .github/scripts/fork-test-shards.cjs create mode 100644 .github/scripts/fork-test-shards.test.cjs create mode 100644 .github/scripts/fork-tests-drift.cjs create mode 100644 .github/scripts/fork-tests-drift.test.cjs rename scripts/{lib => }/release-repository.test.ts (96%) diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt new file mode 100644 index 0000000000000..37279dfa8c754 --- /dev/null +++ b/.github/fork-ci-tests.txt @@ -0,0 +1,26 @@ +# Every test file under .github/scripts (*.test.*, subdirectories included) and +# where it runs (.github/scripts/fork-ci-tests.cjs checks this list). +# run by the workflows job, by extension (sh, py, cjs, mjs, js) +# run by run by , which must name the test's path (not one +# of upstream's workflows: they are disabled in the fork) +# A present test that is not listed fails. A listed file that is not present yet +# is a note; one that was present and then deleted fails. +.github/scripts/fork-ci-tests.test.cjs +.github/scripts/fork-follow.test.sh +.github/scripts/fork-release-workflow.test.py +.github/scripts/fork-release.test.sh +.github/scripts/fork-schema-check.test.cjs +.github/scripts/fork-secret-scan.test.sh +.github/scripts/fork-test-shards.test.cjs +.github/scripts/fork-tests-drift.test.cjs + +# Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. +.github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml +.github/scripts/mobile-preview-comment.test.cjs run by .github/workflows/fork-tests.yml +.github/scripts/stage-preview-bundle.test.py run by .github/workflows/fork-tests.yml +.github/scripts/test-delta.test.cjs run by .github/workflows/fork-tests.yml +.github/scripts/vercel-deployment-output.test.cjs run by .github/workflows/fork-tests.yml +# Upstream runs this one from thread-transfer-report.yml (workflow_run of the disabled CI). +.github/scripts/thread-transfer-report.test.cjs run by .github/workflows/fork-tests.yml +# Release Smoke runs scripts/release-smoke.ts, which runs this one. +.github/scripts/relay-state-output.test.cjs run by scripts/release-smoke.ts diff --git a/.github/fork-tests-upstream.json b/.github/fork-tests-upstream.json new file mode 100644 index 0000000000000..18c65f9f0286f --- /dev/null +++ b/.github/fork-tests-upstream.json @@ -0,0 +1,482 @@ +{ + ".github/workflows/ci.yml": { + "(top-level)": { + "header": "e3b0c44298fc1c14", + "steps": [ + { + "name": "permissions", + "digest": "893e619df25e8e99" + }, + { + "name": "concurrency", + "digest": "8782324e66667532" + } + ] + }, + "changes": { + "header": "8ff211d87838fee8", + "steps": [ + { + "name": "Checkout", + "digest": "6e4f4d400d7166d3" + }, + { + "name": "Detect affected jobs", + "digest": "34a172066c8bd43c" + } + ] + }, + "lint": { + "header": "fb1d2080e81f407b", + "steps": [ + { + "name": "Checkout", + "digest": "b209d3ae4e2481dd" + }, + { + "name": "Reject repository-owned PR assets", + "digest": "a3b3fe56a20d10bc" + }, + { + "name": "Setup Vite+", + "digest": "26d8e10bd2d55595" + }, + { + "name": "Cache Electron download", + "digest": "353e0a76c1955ee0" + }, + { + "name": "Ensure Electron runtime is installed", + "digest": "b49682a395d1db97" + }, + { + "name": "Check unused code", + "digest": "1968f76ced131f47" + }, + { + "name": "Check generated GitHub GraphQL operations", + "digest": "7b0bc86822fffb65" + }, + { + "name": "Check generated Expo GraphQL operations", + "digest": "dfb39b82dec44c75" + }, + { + "name": "Check", + "digest": "b9e0beb00c16198e" + } + ] + }, + "build": { + "header": "7f62c17d569bc89a", + "steps": [ + { + "name": "Checkout", + "digest": "b209d3ae4e2481dd" + }, + { + "name": "./.github/actions/setup-apt-mirrors", + "digest": "0aeff75c5e98e418" + }, + { + "name": "Start installing browser secret helper build libraries", + "digest": "290064f86f52f6b7" + }, + { + "name": "Setup Vite+", + "digest": "26d8e10bd2d55595" + }, + { + "name": "Cache Electron download", + "digest": "353e0a76c1955ee0" + }, + { + "name": "Ensure Electron runtime is installed", + "digest": "154f683f77774ac2" + }, + { + "name": "Start typecheck", + "digest": "7e2b27816c22c788" + }, + { + "name": "Finish installing browser secret helper build libraries", + "digest": "f4047c9d5eb47c6f" + }, + { + "name": "Build desktop pipeline", + "digest": "aee0689d0bf38dc0" + }, + { + "name": "Verify preload bundle output", + "digest": "d70f3343194f687c" + }, + { + "name": "Open the seeded thread in the built desktop app", + "digest": "ef43acf89154cc0d" + }, + { + "name": "Upload desktop launch check", + "digest": "898b201dd5ae5f71" + }, + { + "name": "Resolve Playwright version", + "digest": "21bc211f7756f8f9" + }, + { + "name": "Cache Chromium", + "digest": "404b1c6c70f7eab0" + }, + { + "name": "Install Chromium for the built web client boot check", + "digest": "6bbf4f1dd3173918" + }, + { + "name": "Install missing Chromium system dependencies", + "digest": "1f3b26a2eb8d4245" + }, + { + "name": "Boot the built web client", + "digest": "2d647c22798cb29c" + }, + { + "name": "Typecheck", + "digest": "97be7ff701d1b8dd" + } + ] + }, + "test": { + "header": "0cae18bdc1ae5bb3", + "steps": [ + { + "name": "Checkout", + "digest": "b209d3ae4e2481dd" + }, + { + "name": "./.github/actions/setup-apt-mirrors", + "digest": "14f039b4273b5477" + }, + { + "name": "Start installing browser secret helper build libraries", + "digest": "3b68a19e5e1cf008" + }, + { + "name": "Setup Vite+", + "digest": "26d8e10bd2d55595" + }, + { + "name": "Cache Electron download", + "digest": "353e0a76c1955ee0" + }, + { + "name": "Ensure Electron runtime is installed", + "digest": "b49682a395d1db97" + }, + { + "name": "Test preview artifact validation", + "digest": "8ae0b86bebe56975" + }, + { + "name": "Test release checks", + "digest": "fbb6d647ca2913e8" + }, + { + "name": "Finish installing browser secret helper build libraries", + "digest": "939804bc6530a921" + }, + { + "name": "Test", + "digest": "801fc4dfe59fb6cd" + } + ] + }, + "test_web": { + "header": "f070980792433ae6", + "steps": [ + { + "name": "Checkout", + "digest": "b209d3ae4e2481dd" + }, + { + "name": "Setup Vite+", + "digest": "26d8e10bd2d55595" + }, + { + "name": "Resolve Playwright version", + "digest": "287797def955482a" + }, + { + "name": "Cache Chromium", + "digest": "a26f01a6a03f3797" + }, + { + "name": "Install Chromium for web browser tests", + "digest": "6148d0bd5a6f738e" + }, + { + "name": "Install missing Chromium system dependencies", + "digest": "c2a97e6621813cb4" + }, + { + "name": "Test", + "digest": "fc0c7beecafd3de9" + } + ] + }, + "test_server": { + "header": "88f6bbaecaaa2d68", + "steps": [ + { + "name": "Checkout", + "digest": "b300e7178ac6e5a0" + }, + { + "name": "Detect affected jobs", + "digest": "5648db2117818e11" + }, + { + "name": "Setup Vite+", + "digest": "f939b7b648c8903f" + }, + { + "name": "Resolve Playwright version", + "digest": "5181e0fc26afb953" + }, + { + "name": "Cache Chromium", + "digest": "c11a7e038f53f2e0" + }, + { + "name": "Install Chromium for server browser integration tests", + "digest": "925ae79b31188f3a" + }, + { + "name": "Install missing Chromium system dependencies", + "digest": "862e4a563cb8a6b1" + }, + { + "name": "Restore Vitest module cache", + "digest": "a9f2e0ea95ad6af3" + }, + { + "name": "Test", + "digest": "937c849dc92283cb" + }, + { + "name": "Save Vitest module cache", + "digest": "2af919f0e9798f00" + }, + { + "name": "Detect transfer budget report", + "digest": "6663d3329e713273" + }, + { + "name": "Publish transfer budget report", + "digest": "9e8eac20b2e9877f" + }, + { + "name": "Upload thread transfer result", + "digest": "f063a55279351e0c" + } + ] + }, + "rust": { + "header": "30bedddbe04fd44e", + "steps": [ + { + "name": "Checkout", + "digest": "b209d3ae4e2481dd" + }, + { + "name": "Setup Rust", + "digest": "7e4bade2fcac6c02" + }, + { + "name": "Check Rust formatting", + "digest": "e945fdc4fdd62992" + }, + { + "name": "Test Rust crates", + "digest": "0ecfc49333a39740" + } + ] + }, + "mobile_native_static_analysis": { + "header": "8b1472b3fce2c1bf", + "steps": [ + { + "name": "Checkout", + "digest": "3ac69f008422eb40" + }, + { + "name": "Setup Vite+", + "digest": "6e158a8bbd17c378" + }, + { + "name": "Install mobile native static analysis tools", + "digest": "2ce921f9eed13925" + }, + { + "name": "Lint mobile native sources", + "digest": "828af132b4613197" + } + ] + }, + "release_smoke": { + "header": "d8e29a4630f1113b", + "steps": [ + { + "name": "Checkout", + "digest": "b209d3ae4e2481dd" + }, + { + "name": "Setup Vite+", + "digest": "26d8e10bd2d55595" + }, + { + "name": "Exercise release-only workflow steps", + "digest": "40dae22ed3a65fd4" + } + ] + }, + "check": { + "header": "68e1588ae1870178", + "steps": [ + { + "name": "Require every job to pass", + "digest": "d7af266a25bf845d" + }, + { + "name": "Require transfer measurements", + "digest": "67ca47d5bd5ffc3d" + } + ] + } + }, + ".github/workflows/release.yml": { + "(top-level)": { + "header": "e3b0c44298fc1c14", + "steps": [ + { + "name": "concurrency", + "digest": "7675d281fd1ce2db" + }, + { + "name": "permissions", + "digest": "2fa142cb2564a193" + } + ] + }, + "quality": { + "header": "64aee6ef731d6767", + "steps": [ + { + "name": "Checkout", + "digest": "6c2d53c2bffa53dd" + }, + { + "name": "Setup dependencies", + "digest": "7956327a0eceab8a" + }, + { + "name": "Ensure Electron runtime is installed", + "digest": "b49682a395d1db97" + }, + { + "name": "Check", + "digest": "b9e0beb00c16198e" + }, + { + "name": "Typecheck", + "digest": "a6830e957ddbd801" + } + ] + }, + "test": { + "header": "d304a3329d6f527f", + "steps": [ + { + "name": "Checkout", + "digest": "6c2d53c2bffa53dd" + }, + { + "name": "Setup dependencies", + "digest": "7956327a0eceab8a" + }, + { + "name": "Ensure Electron runtime is installed", + "digest": "b49682a395d1db97" + }, + { + "name": "./.github/actions/setup-apt-mirrors", + "digest": "14f039b4273b5477" + }, + { + "name": "Install browser secret helper build libraries", + "digest": "9ef6c7220ef74e0a" + }, + { + "name": "Test", + "digest": "af6646f21efe6013" + } + ] + }, + "test_web": { + "header": "558b5ad0011b866b", + "steps": [ + { + "name": "Checkout", + "digest": "6c2d53c2bffa53dd" + }, + { + "name": "Setup dependencies", + "digest": "7956327a0eceab8a" + }, + { + "name": "Resolve Playwright version", + "digest": "287797def955482a" + }, + { + "name": "Cache Chromium", + "digest": "a26f01a6a03f3797" + }, + { + "name": "Install Chromium for web browser tests", + "digest": "6148d0bd5a6f738e" + }, + { + "name": "Install missing Chromium system dependencies", + "digest": "c2a97e6621813cb4" + }, + { + "name": "Test", + "digest": "fc0c7beecafd3de9" + }, + { + "name": "Boot the built web client", + "digest": "4ee85e6cd11f1d42" + } + ] + }, + "test_server": { + "header": "dcb7af07046ed9c0", + "steps": [ + { + "name": "Checkout", + "digest": "6c2d53c2bffa53dd" + }, + { + "name": "Setup dependencies", + "digest": "7956327a0eceab8a" + }, + { + "name": "Install Chromium for server browser integration tests", + "digest": "69d36de687d7ae89" + }, + { + "name": "Test", + "digest": "c5d091ce655d8187" + } + ] + } + } +} diff --git a/.github/scripts/fork-ci-tests.cjs b/.github/scripts/fork-ci-tests.cjs new file mode 100644 index 0000000000000..2bca87ed6b6eb --- /dev/null +++ b/.github/scripts/fork-ci-tests.cjs @@ -0,0 +1,172 @@ +// Fork only: every `*.test.*` under .github/scripts must be listed in .github/fork-ci-tests.txt. +// A plain entry runs here by its extension (RUNNER); ` run by ` is run by that file, +// which must name the test and cannot be a (disabled) upstream workflow. Unlisted or runner-less +// tests fail; a listed test that is gone fails once the tested history had it, a note before. +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, "../.."); +const LIST = ".github/fork-ci-tests.txt"; +const SCRIPTS = ".github/scripts"; +const TEST_FILE = /\.test\.([^./]+)$/; +const RUNNER = { + sh: ["bash"], + py: ["python3"], + cjs: ["node", "--test"], + mjs: ["node", "--test"], + js: ["node", "--test"], +}; +const UPSTREAM_WORKFLOW = /^\.github\/workflows\/(?!fork-)[^/]+\.ya?ml$/; + +// `entries`: [{ file, runBy }]; `present`: test files in the tree; +// `ever(file)` says whether the file is in the tested commit's history; +// `read(file)` returns a runner file's text, or null when it does not exist. +function plan(entries, present, ever, read = () => null) { + const listed = entries.map((entry) => entry.file); + const unlisted = present.filter((file) => !listed.includes(file)); + const missing = listed.filter((file) => !present.includes(file)); + const deleted = missing.filter((file) => ever(file)); + const pending = missing.filter((file) => !deleted.includes(file)); + const here = entries.filter((entry) => !entry.runBy && present.includes(entry.file)); + const noRunner = here + .filter((entry) => !RUNNER[entry.file.match(TEST_FILE)?.[1]]) + .map((entry) => entry.file); + const elsewhere = []; + const notRun = []; + for (const { file, runBy } of entries) { + if (!runBy || !present.includes(file)) continue; + let why = null; + if (UPSTREAM_WORKFLOW.test(runBy)) + why = `${runBy} is an upstream workflow, disabled in the fork`; + else { + const text = read(runBy); + if (text === null) why = `${runBy} does not exist`; + else if (!text.includes(file)) why = `${runBy} does not name ${file}`; + } + if (why) notRun.push({ file, why }); + else elsewhere.push({ file, runBy }); + } + return { + run: here.map((entry) => entry.file).filter((file) => !noRunner.includes(file)), + unlisted, + deleted, + pending, + noRunner, + elsewhere, + notRun, + }; +} + +function readList(text) { + return text + .split("\n") + .map((line) => line.replace(/#.*/, "").trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(\S+)(?:\s+run by\s+(\S+))?$/); + if (!match) + throw new Error( + `${LIST}: cannot read "${line}" (expected "" or " run by ")`, + ); + return { file: match[1], runBy: match[2] ?? null }; + }); +} + +function findTests(dir, root = ROOT) { + const out = []; + for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) { + const rel = `${dir}/${entry.name}`; + if (entry.isDirectory()) out.push(...findTests(rel, root)); + else if (TEST_FILE.test(entry.name)) out.push(rel); + } + return out.toSorted(); +} + +function git(args) { + return childProcess.execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); +} + +// A shallow checkout first fetches the full history of HEAD, trees only. +function everInHistory(file) { + if (git(["rev-parse", "--is-shallow-repository"]) === "true") + git([ + "fetch", + "--quiet", + "--no-tags", + "--filter=blob:none", + "--unshallow", + "origin", + git(["rev-parse", "HEAD"]), + ]); + return git(["log", "-1", "--format=%h", "HEAD", "--", file]) !== ""; +} + +function readFile(file) { + try { + return fs.readFileSync(path.join(ROOT, file), "utf8"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +function main() { + const entries = readList(fs.readFileSync(path.join(ROOT, LIST), "utf8")); + const result = plan(entries, findTests(SCRIPTS), everInHistory, readFile); + const rows = []; + let failed = + result.unlisted.length + result.deleted.length + result.noRunner.length + result.notRun.length; + for (const file of result.run) { + const [command, ...args] = RUNNER[file.match(TEST_FILE)[1]]; + const started = Date.now(); + console.log(`::group::${file}`); + const run = childProcess.spawnSync(command, [...args, file], { cwd: ROOT, stdio: "inherit" }); + console.log("::endgroup::"); + const seconds = ((Date.now() - started) / 1000).toFixed(1); + if (run.status !== 0) { + failed += 1; + console.log(`::error file=${file}::${file} failed (exit ${run.status ?? run.signal})`); + } + rows.push(`| \`${file}\` | ${run.status === 0 ? "passed" : "**failed**"} | ${seconds} s |`); + } + for (const file of result.unlisted) + console.log(`::error file=${file}::${file} is not in ${LIST}; add it there`); + for (const file of result.deleted) + console.log(`::error::${file} is listed in ${LIST} but was deleted; restore it or unlist it`); + for (const file of result.noRunner) + console.log( + `::error file=${file}::${file}: no runner for this extension (${Object.keys(RUNNER).join(", ")}); ` + + `rename it or list it as "run by "`, + ); + for (const { file, why } of result.notRun) + console.log(`::error file=${file}::${file} is not run: ${why}`); + for (const { file, runBy } of result.elsewhere) + rows.push(`| \`${file}\` | run by \`${runBy}\` | |`); + for (const file of result.pending) + rows.push(`| \`${file}\` | not present: its PR has not merged | |`); + for (const file of result.unlisted) rows.push(`| \`${file}\` | **not in ${LIST}** | |`); + for (const file of result.deleted) rows.push(`| \`${file}\` | **deleted while listed** | |`); + for (const file of result.noRunner) + rows.push(`| \`${file}\` | **no runner for its extension** | |`); + for (const { file, why } of result.notRun) rows.push(`| \`${file}\` | **not run: ${why}** | |`); + + const summary = process.env.GITHUB_STEP_SUMMARY; + if (summary) + fs.appendFileSync( + summary, + [ + "### Fork script tests", + "", + "| Test | Result | Time |", + "| --- | --- | --- |", + ...rows, + "", + ].join("\n") + "\n", + ); + if (failed) process.exitCode = 1; +} + +module.exports = { plan, readList, findTests }; + +if (require.main === module) main(); diff --git a/.github/scripts/fork-ci-tests.test.cjs b/.github/scripts/fork-ci-tests.test.cjs new file mode 100644 index 0000000000000..28c72efa155b6 --- /dev/null +++ b/.github/scripts/fork-ci-tests.test.cjs @@ -0,0 +1,98 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { plan, readList, findTests } = require("./fork-ci-tests.cjs"); + +const never = () => false; +const here = (...files) => files.map((file) => ({ file, runBy: null })); + +test("runs every listed present test with a runner", () => { + assert.deepEqual(plan(here("a.test.sh", "b.test.mjs"), ["a.test.sh", "b.test.mjs"], never).run, [ + "a.test.sh", + "b.test.mjs", + ]); +}); + +test("a present test missing from the list fails", () => { + assert.deepEqual(plan(here("a.test.sh"), ["a.test.sh", "b.test.js"], never).unlisted, [ + "b.test.js", + ]); +}); + +test("a listed test with no runner for its extension fails and is not run", () => { + const result = plan(here("a.test.ts"), ["a.test.ts"], never); + assert.deepEqual(result.noRunner, ["a.test.ts"]); + assert.deepEqual(result.run, []); +}); + +test("a listed test never in history is pending, not a failure", () => { + const result = plan(here("a.test.sh", "later.test.sh"), ["a.test.sh"], never); + assert.deepEqual(result.pending, ["later.test.sh"]); + assert.deepEqual(result.deleted, []); +}); + +test("a listed test deleted from history fails", () => { + const result = plan( + here("a.test.sh", "gone.test.sh"), + ["a.test.sh"], + (f) => f === "gone.test.sh", + ); + assert.deepEqual(result.deleted, ["gone.test.sh"]); + assert.deepEqual(result.pending, []); +}); + +test("a test run elsewhere must be named by its runner, which is not an upstream workflow", () => { + const files = { + ".github/workflows/fork-tests.yml": "run: node --test x/named.test.cjs", + ".github/workflows/ci.yml": "run: node --test x/upstream.test.cjs", + }; + const entries = [ + { file: "x/named.test.cjs", runBy: ".github/workflows/fork-tests.yml" }, + { file: "x/unnamed.test.cjs", runBy: ".github/workflows/fork-tests.yml" }, + { file: "x/upstream.test.cjs", runBy: ".github/workflows/ci.yml" }, + { file: "x/nowhere.test.cjs", runBy: "scripts/missing.ts" }, + ]; + const result = plan( + entries, + entries.map((e) => e.file), + never, + (file) => files[file] ?? null, + ); + assert.deepEqual(result.run, []); + assert.deepEqual(result.elsewhere, [ + { file: "x/named.test.cjs", runBy: ".github/workflows/fork-tests.yml" }, + ]); + assert.deepEqual( + result.notRun.map((n) => n.file), + ["x/unnamed.test.cjs", "x/upstream.test.cjs", "x/nowhere.test.cjs"], + ); + assert.match(result.notRun[1].why, /upstream workflow/); +}); + +test("the list ignores comments and blank lines and reads `run by`", () => { + assert.deepEqual(readList("# head\n\na.test.sh # from #2\n b.test.py run by c.yml\n"), [ + { file: "a.test.sh", runBy: null }, + { file: "b.test.py", runBy: "c.yml" }, + ]); + assert.throws(() => readList("a.test.sh run c.yml\n"), /cannot read/); +}); + +test("finds every *.test.* file, subdirectories included", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fork-ci-tests-")); + try { + for (const file of ["s/a.test.cjs", "s/b.cjs", "s/sub/c.test.mjs", "s/sub/deep/d.test.ts"]) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), ""); + } + assert.deepEqual(findTests("s", root), [ + "s/a.test.cjs", + "s/sub/c.test.mjs", + "s/sub/deep/d.test.ts", + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/fork-secret-scan.sh b/.github/scripts/fork-secret-scan.sh new file mode 100755 index 0000000000000..b6411894d226a --- /dev/null +++ b/.github/scripts/fork-secret-scan.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Fork only: the Secret scan job of fork-tests.yml. Picks the commits a pull request or a push adds +# and runs gitleaks over them. Tests: fork-secret-scan.test.sh. +# +# Environment: +# GITLEAKS gitleaks binary +# EVENT_NAME pull_request or push +# BASE_SHA pull_request: the base branch commit the PR is compared with +# HEAD_SHA pull_request: the PR head +# BEFORE push: the branch tip before the push (all zeros when the branch is new) +# AFTER push: the branch tip after the push +# +# Ranges: +# pull_request BASE_SHA..HEAD_SHA: every commit the PR adds, merges of the base branch +# included, whatever their dates. A squash merge never brings the PR's own commits +# to main, but they stay public under refs/pull/N/head, so they are scanned here. +# push BEFORE..AFTER. After a force push that is every commit the new tip has and the +# old one had not; commits both share were scanned when they were first pushed. +# push, no BEFORE a new branch (BEFORE all zeros) or an old tip that can no longer be fetched: +# only AFTER itself, with a warning. Everything before it is history this fork +# already had (upstream's, or scanned by earlier runs), and there is no older tip +# to tell new commits from old ones. +# Merge commits are diffed with --remerge-diff: only what the merge itself changed beyond an +# automatic merge of its parents (a conflict resolution, or a file added in the merge). Without it +# git log shows merges with no diff at all, so gitleaks would never read them. +# +# Offline, and counted: the checkout is a blob-less clone, so file contents are fetched on demand. +# gitleaks 8.30.1 stops reading at the first git error (a blob fetch that fails: network, GitHub +# 5xx), logs "0 commits scanned" and still exits 0. So the script runs gitleaks' own git log first, +# which fetches every blob the scan reads and fails the job if a fetch fails, then runs gitleaks with +# GIT_NO_LAZY_FETCH=1, and fails when gitleaks scanned fewer commits than that git log holds. +# gitleaks counts a commit when a file it neither deletes nor turns binary has a hunk: a pure +# rename, a mode change, an empty new file or a merge with nothing beyond the automatic merge has +# none, so the count can be below `git rev-list --count`. +set -euo pipefail + +have() { git cat-file -e "$1^{commit}" 2>/dev/null; } +# Commits only, file contents on demand: the checkout is a blob-less clone. +fetch() { + have "$1" || git fetch --no-tags --quiet --filter=blob:none origin "$1" 2>/dev/null || true + have "$1" +} +zero() { [[ -z "$1" || "$1" =~ ^0+$ ]]; } +summary() { if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then printf '%s\n' "$@" >> "$GITHUB_STEP_SUMMARY"; fi; } + +warning="" +case "$EVENT_NAME" in + pull_request) + for sha in "$BASE_SHA" "$HEAD_SHA"; do + if ! fetch "$sha"; then + echo "::error::Secret scan: cannot fetch commit $sha" >&2 + exit 2 + fi + done + range="$BASE_SHA..$HEAD_SHA" + opts="--remerge-diff $range" + what="the pull request's $(git rev-list --count "$range") commit(s) after base ${BASE_SHA:0:12}, merges included" + ;; + push) + if ! zero "$BEFORE" && fetch "$BEFORE"; then + range="$BEFORE..$AFTER" + opts="--remerge-diff $range" + if git merge-base --is-ancestor "$BEFORE" "$AFTER"; then + what="the $(git rev-list --count "$range") commit(s) pushed after ${BEFORE:0:12}" + else + what="force push: the $(git rev-list --count "$range") commit(s) ${AFTER:0:12} has that the replaced tip ${BEFORE:0:12} had not" + fi + else + range="--max-count=1 $AFTER" + opts="--remerge-diff $range" + if zero "$BEFORE"; then + warning="a new branch has no earlier tip, so only its newest commit ${AFTER:0:12} is scanned" + else + warning="the earlier tip ${BEFORE:0:12} cannot be fetched (force push?), so only the newest commit ${AFTER:0:12} is scanned" + fi + what="the pushed commit ${AFTER:0:12}" + fi + ;; + *) + echo "::error::Secret scan: unexpected event $EVENT_NAME" >&2 + exit 2 + ;; +esac + +echo "Scanning $what ($range)" +if [[ -n "$warning" ]]; then echo "::warning title=Secret scan::$warning"; fi + +# gitleaks runs `git log -p -U0 `. The same command here fetches what it reads; counts the +# commits gitleaks will scan (see the header); and fails the job (pipefail) when a fetch fails. +# shellcheck disable=SC2086 +if ! expected="$(git log -p -U0 $opts | awk ' + /^commit / { sha = $2; next } + /^diff --git / { deleted = 0; next } + /^deleted file mode / { deleted = 1; next } + /^@@ / { if (!deleted && sha != "" && !(sha in seen)) { seen[sha] = 1; n++ } } + END { print n + 0 }')"; then + echo "::error::Secret scan: cannot read the commits to scan (a file content fetch failed?)" >&2 + summary "### Secret scan" "" "Failed: the contents of $what could not be read, so nothing was scanned." + exit 2 +fi + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT +set +e +GIT_NO_LAZY_FETCH=1 "$GITLEAKS" git --no-banner --no-color --redact --verbose --log-opts="$opts" . > "$log" 2>&1 +status=$? +set -e +cat "$log" +scanned="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | sed -nE 's/.* ([0-9]+) commits scanned.*/\1/p' | tail -n 1)" +echo "gitleaks scanned ${scanned:-no} commit(s); $expected commit(s) in range have changes it reads" +summary "### Secret scan" "" +if [[ -n "$warning" ]]; then summary "Warning: $warning." ""; fi +if [[ $status -eq 0 && ( -z "$scanned" || "$scanned" -lt "$expected" ) ]]; then + echo "::error::Secret scan: gitleaks scanned ${scanned:-no} commit(s), but $expected commit(s) in range have changes (a git error stopped it?)" >&2 + summary "Failed: gitleaks scanned ${scanned:-no} of the $expected commit(s) with changes in $what, so the rest were not checked." + exit 2 +fi +if [[ $status -eq 0 ]]; then + summary "No secrets found in $what ($scanned commit(s) with changes scanned)." +else + summary "gitleaks found possible secrets in $what (values redacted; see the job log). Rotate any real secret: it is already public." +fi +exit "$status" diff --git a/.github/scripts/fork-secret-scan.test.sh b/.github/scripts/fork-secret-scan.test.sh new file mode 100755 index 0000000000000..e5a0c191a1291 --- /dev/null +++ b/.github/scripts/fork-secret-scan.test.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Tests for fork-secret-scan.sh against local git repositories; no network. +# bash .github/scripts/fork-secret-scan.test.sh +# GITLEAKS_BIN=path runs the real gitleaks; without it a stand-in greps the scanned commits' diffs +# for an AWS access key id. FORK_SECRET_SCAN_SH picks another script. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$(realpath "${FORK_SECRET_SCAN_SH:-$here/fork-secret-scan.sh}")" +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT + +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +unset GITHUB_STEP_SUMMARY GITHUB_OUTPUT + +if [[ -n "${GITLEAKS_BIN:-}" ]]; then + gitleaks="$(realpath "$GITLEAKS_BIN")" +else + gitleaks="$root/fake-gitleaks" + export TMPDIR="$root" + cat > "$gitleaks" <<'FAKE' +#!/usr/bin/env bash +# Stand-in: `gitleaks git ... --log-opts=OPTS .` fails when the diffs of `git log -p -U0 OPTS` add a +# key id, and logs how many commits it scanned. Like gitleaks 8.30.1, a git error (a file content +# fetch that fails) stops it with "0 commits scanned" and exit 0. +set -uo pipefail +opts="" +for arg in "$@"; do [[ "$arg" == --log-opts=* ]] && opts="${arg#--log-opts=}"; done +# Into a file first: `grep -q` stops reading early, and pipefail would read git's SIGPIPE as no match. +# shellcheck disable=SC2086 +if ! git log -p -U0 $opts > "$TMPDIR/fake-gitleaks.log" 2> "$TMPDIR/fake-gitleaks.err"; then + echo "ERR [git] $(head -n 1 "$TMPDIR/fake-gitleaks.err")" + echo "INF 0 commits scanned." + echo "no leaks found" + exit 0 +fi +# A commit counts when a file it does not delete has a hunk, as in gitleaks. +scanned="$(awk '/^commit / { c = $2 } /^diff --git / { d = 0 } /^deleted file mode / { d = 1 } + /^@@ / && !d && !(c in s) { s[c] = 1; n++ } END { print n + 0 }' "$TMPDIR/fake-gitleaks.log")" +echo "INF $scanned commits scanned." +if grep -qE '^\+.*(AKIA)[A-Z2-7]{16}' "$TMPDIR/fake-gitleaks.log"; then echo "leaks found: 1"; exit 1; fi +echo "no leaks found" +FAKE + chmod +x "$gitleaks" +fi + +# A made-up AWS access key id, built at run time so this file holds no key. +key="AK""IA""QX7WMB3KZLRT5NHP" +date=1700000000 +commit() { # commit FILE CONTENT MESSAGE (each commit one minute newer) + date=$((date + 60)) + printf '%s\n' "$2" > "$1" + git add -A + GIT_AUTHOR_DATE="@$date" GIT_COMMITTER_DATE="@$date" git commit -qm "$3" +} +remove() { + date=$((date + 60)) + git rm -q "$1" + GIT_AUTHOR_DATE="@$date" GIT_COMMITTER_DATE="@$date" git commit -qm "$2" +} +merge() { + date=$((date + 60)) + GIT_AUTHOR_DATE="@$date" GIT_COMMITTER_DATE="@$date" git merge -q --no-ff -m "$2" "$1" +} + +failures=0 +check() { # check NAME EXPECTED_STATUS [GREP_PATTERN] -- runs the scan in $clone with the env given + local name="$1" expected="$2" pattern="${3:-}" out status + set +e + out="$(cd "$clone" && GITLEAKS="$gitleaks" GITHUB_STEP_SUMMARY="$root/summary" "$script" 2>&1)" + status=$? + set -e + if [[ "$status" -ne "$expected" ]]; then + echo "FAIL $name: exit $status, expected $expected" + printf '%s\n' "$out" | sed 's/^/ /' + failures=$((failures + 1)) + elif [[ -n "$pattern" ]] && ! grep -qE "$pattern" <<< "$out"; then + echo "FAIL $name: output lacks /$pattern/" + printf '%s\n' "$out" | sed 's/^/ /' + failures=$((failures + 1)) + else + echo "ok $name" + fi + rm -f "$root/summary" +} + +# origin: main with an old key on it (already public, not this PR's business), then two PR +# branches that each merge three newer main commits. +git init -q --bare "$root/origin.git" +git -C "$root/origin.git" config uploadpack.allowFilter true +git -C "$root/origin.git" config uploadpack.allowAnySHA1InWant true +git init -q "$root/work" +cd "$root/work" +git remote add origin "$root/origin.git" +commit app.txt "app v1" "main 1" +commit old.txt "old=$key" "main 2: an old key" +remove old.txt "main 3: drop the old key" +fork_point=$(git rev-parse HEAD) + +git checkout -q -b leaky +commit config.txt "aws_access_key_id=$key" "PR A: add a key" +remove config.txt "PR B: delete it again" +git checkout -q -b clean "$fork_point" +commit notes.txt "notes" "PR A': no key" + +git checkout -q main +for n in 4 5 6; do commit "app$n.txt" "app v$n" "main $n"; done +base=$(git rev-parse HEAD) + +for branch in leaky clean; do + git checkout -q "$branch" + merge main "Merge main into $branch" + commit "$branch-tail.txt" "tail" "PR C: after the merge" +done +# A key that only the merge commit adds (on top of an automatic merge of main). +git checkout -q -b evil "$fork_point" +commit notes2.txt "notes" "PR A'': no key" +git merge -q --no-ff --no-commit main >/dev/null 2>&1 +printf '%s\n' "token=$key" > merged.txt +git add merged.txt +date=$((date + 60)) +GIT_AUTHOR_DATE="@$date" GIT_COMMITTER_DATE="@$date" git commit -qm "Merge main into evil" +git push -q origin main leaky clean evil +leaky=$(git rev-parse leaky) +clean=$(git rev-parse clean) +evil=$(git rev-parse evil) + +# The workflow's checkout: blob-less, only the PR head's history (no main ref). +clone="$root/clone" +git clone -q --filter=blob:none --no-checkout --single-branch --branch leaky "file://$root/origin.git" "$clone" + +export EVENT_NAME=pull_request BASE_SHA="$base" BEFORE="" AFTER="" +# The count GitHub reports for the PR, for scripts that use it. +COMMITS=$(git rev-list --count "$base..$leaky") +export COMMITS +HEAD_SHA="$leaky" check "pull request: a key added then deleted before a merge of main is found" 1 +HEAD_SHA="$clean" check "pull request: main's own old key is not the PR's" 0 +HEAD_SHA="$leaky" check "pull request: the range is base..head" 1 "\\.\\.$leaky" +HEAD_SHA="$evil" check "pull request: a key added only in a merge commit is found" 1 + +export EVENT_NAME=push BASE_SHA="" HEAD_SHA="" +BEFORE="$fork_point" AFTER="$leaky" check "push: the pushed commits are scanned" 1 "pushed after" +BEFORE="$(git rev-parse leaky~1)" AFTER="$leaky" check "push: only commits after the old tip" 0 +BEFORE="$clean" AFTER="$leaky" check "force push: commits the old tip had not" 1 "force push" +BEFORE="$fork_point" AFTER="$evil" check "push: a key added only in a merge commit is found" 1 +BEFORE="0000000000000000000000000000000000000000" AFTER="$leaky" \ + check "push of a new branch: only its newest commit, with a warning" 0 "::warning.*new branch" +BEFORE="1111111111111111111111111111111111111111" AFTER="$leaky" \ + check "push: an old tip that cannot be fetched warns" 0 "::warning.*cannot be fetched" + +EVENT_NAME=workflow_dispatch check "an unknown event fails" 2 + +export EVENT_NAME=pull_request BEFORE="" AFTER="" +BASE_SHA="1111111111111111111111111111111111111111" HEAD_SHA="$leaky" \ + check "pull request: a base commit that cannot be fetched fails" 2 "cannot fetch commit 1111" +BASE_SHA="$base" HEAD_SHA="2222222222222222222222222222222222222222" \ + check "pull request: a head commit that cannot be fetched fails" 2 "cannot fetch commit 2222" + +# gitleaks stopping early (a git error part-way) without failing: it scans only the newest commit. +real_gitleaks="$gitleaks" +gitleaks="$root/early-stop-gitleaks" +cat > "$gitleaks" <` for a large package every shard runs a vitest slice of, `whole ` +// for the rest, heaviest first (by test files) onto the lightest shard. +// TEST_PACKAGES from `changes` limits the set; empty means every package. +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { readWorkspace } = require("./ci-changes.cjs"); + +// Tested by their own jobs (Test Server, Test Web) or not a package to test. +const OWN_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/; +// One vitest run of this many files outlasts the rest of a shard. +const SPLIT_AT = 100; + +function assign(packages, count) { + const split = packages.filter((pkg) => pkg.splittable && pkg.weight >= SPLIT_AT); + const share = split.reduce((total, pkg) => total + pkg.weight, 0) / count; + const shards = Array.from({ length: count }, () => ({ weight: share, whole: [], split })); + const ordered = packages + .filter((pkg) => !split.includes(pkg)) + .toSorted((a, b) => b.weight - a.weight || a.name.localeCompare(b.name)); + for (const pkg of ordered) { + const lightest = shards.reduce((best, shard) => (shard.weight < best.weight ? shard : best)); + lightest.weight += pkg.weight; + lightest.whole.push(pkg); + } + return shards; +} + +function testFiles(dir) { + return childProcess + .execFileSync("git", ["ls-files", "-z", "--", dir], { encoding: "utf8", maxBuffer: 1 << 26 }) + .split("\0") + .filter((file) => TEST_FILE.test(file)).length; +} + +function main() { + const match = (process.argv[2] ?? "").match(/^(\d+)\/(\d+)$/); + if (!match || Number(match[1]) < 1 || Number(match[1]) > Number(match[2])) + throw new Error("usage: fork-test-shards.cjs /"); + const [index, count] = [Number(match[1]), Number(match[2])]; + const only = (process.env.TEST_PACKAGES ?? "").split(/\s+/).filter(Boolean); + const root = path.resolve(__dirname, "../.."); + const packages = readWorkspace() + .filter((pkg) => pkg.hasTest && !OWN_JOB.has(pkg.name)) + .filter((pkg) => only.length === 0 || only.includes(pkg.name)) + .map((pkg) => { + const manifest = JSON.parse( + fs.readFileSync(path.join(root, pkg.dir, "package.json"), "utf8"), + ); + return { + name: pkg.name, + dir: pkg.dir, + weight: Math.max(1, testFiles(pkg.dir)), + // Run in its directory with --shard, so the script must be vitest itself. + splittable: /^vp test run(\s|$)/.test(manifest.scripts.test), + }; + }); + const unknown = only.filter((name) => !packages.some((pkg) => pkg.name === name)); + if (unknown.length) + throw new Error(`TEST_PACKAGES names no tested package: ${unknown.join(" ")}`); + + const shard = assign(packages, count)[index - 1]; + for (const pkg of shard.split) console.log(`split ${pkg.dir}`); + for (const pkg of shard.whole) console.log(`whole ${pkg.name}`); + const summary = process.env.GITHUB_STEP_SUMMARY; + if (summary) { + const list = (pkgs) => pkgs.map((pkg) => `\`${pkg.name}\` (${pkg.weight})`).join(", "); + const lines = [`### Test shard ${index}/${count}`, ""]; + if (shard.split.length) lines.push(`Slice ${index}/${count} of ${list(shard.split)}.`, ""); + if (shard.whole.length) lines.push(`Whole: ${list(shard.whole)}.`, ""); + if (!shard.split.length && !shard.whole.length) + lines.push( + `Nothing to run: the ${packages.length} affected package(s) are on other shards.`, + "", + ); + fs.appendFileSync(summary, `${lines.join("\n")}\n`); + } +} + +module.exports = { assign, SPLIT_AT }; + +if (require.main === module) main(); diff --git a/.github/scripts/fork-test-shards.test.cjs b/.github/scripts/fork-test-shards.test.cjs new file mode 100644 index 0000000000000..81310927ed4a5 --- /dev/null +++ b/.github/scripts/fork-test-shards.test.cjs @@ -0,0 +1,38 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { assign, SPLIT_AT } = require("./fork-test-shards.cjs"); + +const pkg = (name, weight, splittable = true) => ({ + name, + dir: `packages/${name}`, + weight, + splittable, +}); +const whole = (shards) => shards.map((shard) => shard.whole.map((p) => p.name)); +const split = (shards) => shards.map((shard) => shard.split.map((p) => p.name)); + +test("places the heaviest package first on the lightest shard", () => { + const packages = [pkg("small", 1), pkg("big", 10), pkg("mid", 6), pkg("mid2", 5)]; + assert.deepEqual(whole(assign(packages, 2)), [ + ["big", "small"], + ["mid", "mid2"], + ]); +}); + +test("ties break by name, so every shard computes the same split", () => { + const packages = [pkg("b", 2), pkg("a", 2), pkg("c", 2)]; + assert.deepEqual(whole(assign(packages, 2)), [["a", "c"], ["b"]]); + assert.deepEqual(whole(assign(packages.toReversed(), 2)), [["a", "c"], ["b"]]); +}); + +test("a large vitest package is sliced on every shard, the rest stay whole", () => { + const packages = [pkg("large", SPLIT_AT + 50), pkg("script", SPLIT_AT, false), pkg("a", 3)]; + const shards = assign(packages, 2); + assert.deepEqual(split(shards), [["large"], ["large"]]); + assert.deepEqual(whole(shards), [["script"], ["a"]]); +}); + +test("every package lands on exactly one shard, and spare shards stay empty", () => { + assert.deepEqual(whole(assign([pkg("only", 3)], 3)), [["only"], [], []]); +}); diff --git a/.github/scripts/fork-tests-drift.cjs b/.github/scripts/fork-tests-drift.cjs new file mode 100644 index 0000000000000..46d835d0afeeb --- /dev/null +++ b/.github/scripts/fork-tests-drift.cjs @@ -0,0 +1,244 @@ +// Fork only: fails when upstream's ci.yml or release.yml test jobs or TOP_LEVEL keys changed since +// .github/fork-tests-upstream.json recorded them (comments ignored). It compares upstream with its +// record, not with fork-tests.yml: `--record` prints the changes and saves them, and its commit and +// PR must say per change what was ported or why it does not apply. Run: `node [--record]`. +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, "../.."); +const recordPath = path.join(root, ".github/fork-tests-upstream.json"); + +// Upstream file -> jobs the fork copies (null = every job in the file). +const TRACKED = { + ".github/workflows/ci.yml": null, + ".github/workflows/release.yml": ["quality", "test", "test_web", "test_server"], +}; + +// Top-level keys digested as the pseudo-job TOP (job ids cannot contain parentheses). +const TOP_LEVEL = ["env", "defaults", "concurrency", "permissions"]; +const TOP = "(top-level)"; + +const digest = (lines) => + crypto.createHash("sha256").update(lines.join("\n")).digest("hex").slice(0, 16); + +const indentOf = (line) => line.length - line.trimStart().length; + +function parseJobs(text) { + // Drop blank lines, full-line comments and trailing spaces. + const lines = text + .split("\n") + .map((line) => line.replace(/\s+$/, "")) + .filter((line) => line.trim() !== "" && !line.trim().startsWith("#")); + const start = lines.findIndex((line) => line === "jobs:"); + if (start === -1) throw new Error("no top-level `jobs:` key"); + + // Top-level keys, wherever they are in the file: one "step" per key present. + const top = []; + let topKey = null; + for (const line of lines) { + if (indentOf(line) === 0) { + const key = line.match(/^([A-Za-z0-9_-]+):/)?.[1]; + topKey = TOP_LEVEL.includes(key) ? { name: key, lines: [] } : null; + if (topKey) top.push(topKey); + } + topKey?.lines.push(line); + } + + const jobs = {}; + let job = null; + let inSteps = false; + let stepsIndent = 0; + let step = null; + for (const line of lines.slice(start + 1)) { + if (indentOf(line) === 0) break; + const jobKey = line.match(/^ {2}([A-Za-z0-9_-]+):$/); + if (jobKey) { + job = jobs[jobKey[1]] = { header: [], steps: [] }; + inSteps = false; + step = null; + continue; + } + if (!job) continue; + if (inSteps && indentOf(line) <= stepsIndent && !line.trimStart().startsWith("- ")) { + inSteps = false; + step = null; + } + if (!inSteps && /^\s+steps:$/.test(line)) { + inSteps = true; + stepsIndent = indentOf(line); + continue; + } + if (!inSteps) { + job.header.push(line); + continue; + } + if (indentOf(line) <= stepsIndent + 2 && line.trimStart().startsWith("- ")) { + step = { lines: [] }; + job.steps.push(step); + } + step?.lines.push(line); + } + + const result = {}; + if (top.length) + result[TOP] = { + header: digest([]), + steps: top.map((key) => ({ name: key.name, digest: digest(key.lines) })), + }; + for (const [name, { header, steps }] of Object.entries(jobs)) { + const seen = new Map(); + result[name] = { + header: digest(header), + steps: steps.map((s) => { + const named = s.lines + .map((line) => line.trim().replace(/^- /, "")) + .map((line) => line.match(/^(?:name|uses):\s*(.+)$/)) + .find(Boolean); + let label = named ? named[1].replace(/^["']|["']$/g, "") : "(unnamed step)"; + // Repeated labels get a counter so each step keeps one identity. + const count = (seen.get(label) ?? 0) + 1; + seen.set(label, count); + if (count > 1) label = `${label} #${count}`; + return { name: label, digest: digest(s.lines) }; + }), + }; + } + return result; +} + +// Returns the digests and the tracked jobs upstream no longer has. +function current() { + const out = {}; + const missing = []; + for (const [file, only] of Object.entries(TRACKED)) { + const jobs = parseJobs(fs.readFileSync(path.join(root, file), "utf8")); + if (only) { + for (const name of only) if (!jobs[name]) missing.push(`${file}: job \`${name}\``); + out[file] = Object.fromEntries( + [TOP, ...only].filter((name) => jobs[name]).map((name) => [name, jobs[name]]), + ); + } else { + out[file] = jobs; + } + } + return { now: out, missing }; +} + +function compare(recorded, now) { + const changes = []; + for (const file of new Set([...Object.keys(recorded), ...Object.keys(now)])) { + const before = recorded[file] ?? {}; + const after = now[file] ?? {}; + for (const job of new Set([...Object.keys(before), ...Object.keys(after)])) { + // A record saved before --record refused missing jobs may hold null. + const a = before[job] ?? null; + const b = after[job] ?? null; + if (job === TOP) { + const oldKeys = new Map((a?.steps ?? []).map((s) => [s.name, s.digest])); + const newKeys = new Map((b?.steps ?? []).map((s) => [s.name, s.digest])); + for (const [key, d] of newKeys) { + if (!oldKeys.has(key)) changes.push(`${file}: top-level \`${key}:\` added`); + else if (oldKeys.get(key) !== d) changes.push(`${file}: top-level \`${key}:\` changed`); + } + for (const key of oldKeys.keys()) + if (!newKeys.has(key)) changes.push(`${file}: top-level \`${key}:\` removed`); + continue; + } + if (!a && !b) { + changes.push(`${file}: job \`${job}\` missing upstream (renamed or removed?)`); + continue; + } + if (!a) { + changes.push(`${file}: job \`${job}\` added upstream`); + continue; + } + if (!b) { + changes.push(`${file}: job \`${job}\` removed upstream`); + continue; + } + if (a.header !== b.header) + changes.push( + `${file}: job \`${job}\`: settings changed (runs-on, if, needs, matrix, env...)`, + ); + const oldSteps = new Map(a.steps.map((s) => [s.name, s.digest])); + const newSteps = new Map(b.steps.map((s) => [s.name, s.digest])); + for (const [name, d] of newSteps) { + if (!oldSteps.has(name)) changes.push(`${file}: job \`${job}\`: step "${name}" added`); + else if (oldSteps.get(name) !== d) + changes.push(`${file}: job \`${job}\`: step "${name}" changed`); + } + for (const name of oldSteps.keys()) + if (!newSteps.has(name)) changes.push(`${file}: job \`${job}\`: step "${name}" removed`); + const oldOrder = a.steps.map((s) => s.name).filter((n) => newSteps.has(n)); + const newOrder = b.steps.map((s) => s.name).filter((n) => oldSteps.has(n)); + if (oldOrder.join("\0") !== newOrder.join("\0")) + changes.push(`${file}: job \`${job}\`: steps reordered`); + } + } + return changes; +} + +function readRecord(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return {}; + throw error; + } +} + +// Refuses while a tracked job is missing: the copy in fork-tests.yml then follows +// a job upstream no longer has, and TRACKED must be updated first. +function record(now, missing, file = recordPath) { + if (missing.length) { + console.error( + `Not recorded: tracked jobs missing upstream: ${missing.join(", ")}.\n` + + "Find what replaced them, port that into fork-tests.yml and update TRACKED in " + + "fork-tests-drift.cjs, then record again.", + ); + process.exitCode = 1; + return; + } + const changes = compare(readRecord(file), now); + fs.writeFileSync(file, `${JSON.stringify(now, null, 2)}\n`); + console.log(`Recorded ${path.relative(root, file)}.`); + if (changes.length === 0) { + console.log("Nothing changed upstream since the last record."); + return; + } + console.log( + `\nUpstream changes this record accepts (${changes.length}):\n` + + changes.map((c) => `- ${c}`).join("\n") + + "\n\nSay in the commit message and the PR body, for each line, what was ported " + + "into fork-tests.yml or why it does not apply. The check cannot tell; reviewers rely on it.", + ); +} + +function main() { + const { now, missing } = current(); + if (process.argv.includes("--record")) return record(now, missing); + const recorded = JSON.parse(fs.readFileSync(recordPath, "utf8")); + const changes = compare(recorded, now); + const summary = process.env.GITHUB_STEP_SUMMARY; + if (changes.length === 0) { + const msg = "Upstream test jobs match the copy in fork-tests.yml (recorded digest)."; + console.log(msg); + if (summary) fs.appendFileSync(summary, `### Upstream drift\n\n${msg}\n`); + return; + } + const report = + "### Upstream drift\n\n" + + "Upstream's test jobs changed since `.github/workflows/fork-tests.yml` last followed them:\n\n" + + changes.map((c) => `- ${c}`).join("\n") + + "\n\nPort the change into fork-tests.yml (or decide it does not apply and say why in the PR), then run " + + "`node .github/scripts/fork-tests-drift.cjs --record` and commit `.github/fork-tests-upstream.json`.\n"; + console.error(report); + if (summary) fs.appendFileSync(summary, report); + for (const c of changes) console.log(`::error title=Upstream drift::${c}`); + process.exitCode = 1; +} + +module.exports = { parseJobs, compare, record, TOP }; + +if (require.main === module) main(); diff --git a/.github/scripts/fork-tests-drift.test.cjs b/.github/scripts/fork-tests-drift.test.cjs new file mode 100644 index 0000000000000..7d869a3e49e62 --- /dev/null +++ b/.github/scripts/fork-tests-drift.test.cjs @@ -0,0 +1,121 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { parseJobs, compare, record } = require("./fork-tests-drift.cjs"); + +const base = `name: CI +on: push +jobs: + lint: + runs-on: ubuntu-latest + steps: + # comment + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + - uses: ./.github/actions/setup + - name: Check + run: | + vp check + test: + needs: [lint] + strategy: + matrix: + shard: [1, 2] + steps: + - name: Test + run: vp test +`; + +const wrap = (text) => ({ "ci.yml": parseJobs(text) }); + +test("parses jobs and steps, ignoring comments and blank lines", () => { + const jobs = parseJobs(base); + assert.deepEqual(Object.keys(jobs), ["lint", "test"]); + assert.deepEqual( + jobs.lint.steps.map((s) => s.name), + ["Checkout", "./.github/actions/setup", "Check"], + ); + const noisy = base.replace(" - name: Check", "\n # new comment\n - name: Check"); + assert.deepEqual(compare(wrap(base), wrap(noisy)), []); +}); + +test("reports added, removed, changed and reordered steps and jobs", () => { + const changed = base + .replace("vp check", "vp check --fix") + .replace(" - uses: ./.github/actions/setup\n", "") + .replace("shard: [1, 2]", "shard: [1, 2, 3]") + .concat(" rust:\n steps:\n - name: Cargo\n run: cargo test\n"); + assert.deepEqual(compare(wrap(base), wrap(changed)), [ + 'ci.yml: job `lint`: step "Check" changed', + 'ci.yml: job `lint`: step "./.github/actions/setup" removed', + "ci.yml: job `test`: settings changed (runs-on, if, needs, matrix, env...)", + "ci.yml: job `rust` added upstream", + ]); + const reordered = base.replace( + " - uses: ./.github/actions/setup\n - name: Check\n run: |\n vp check\n", + " - name: Check\n run: |\n vp check\n - uses: ./.github/actions/setup\n", + ); + assert.deepEqual(compare(wrap(base), wrap(reordered)), ["ci.yml: job `lint`: steps reordered"]); +}); + +test("repeated step names stay distinct", () => { + const twice = base.replace(" test:", " - name: Check\n run: echo again\n test:"); + const names = parseJobs(twice).lint.steps.map((s) => s.name); + assert.deepEqual(names.slice(-2), ["Check", "Check #2"]); +}); + +test("top-level env, defaults, concurrency and permissions count, wherever they are", () => { + const changed = `env:\n FOO: bar\n${base}defaults:\n run:\n shell: bash\n`; + assert.deepEqual(compare(wrap(base), wrap(changed)), [ + "ci.yml: top-level `env:` added", + "ci.yml: top-level `defaults:` added", + ]); + const withPerms = base.replace("jobs:", "permissions:\n contents: read\njobs:"); + assert.deepEqual(compare(wrap(withPerms), wrap(withPerms.replace(": read", ": write"))), [ + "ci.yml: top-level `permissions:` changed", + ]); + assert.deepEqual(compare(wrap(withPerms), wrap(base)), [ + "ci.yml: top-level `permissions:` removed", + ]); + // `on:` and `name:` are not digested. + assert.deepEqual(compare(wrap(base), wrap(base.replace("on: push", "on: pull_request"))), []); +}); + +test("a tracked job missing on both sides is reported, not a crash", () => { + const recorded = { "release.yml": { test_web: null } }; + assert.deepEqual(compare(recorded, { "release.yml": {} }), [ + "release.yml: job `test_web` missing upstream (renamed or removed?)", + ]); +}); + +test("--record refuses while a tracked job is missing and writes nothing", () => { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-drift-")); + const file = path.join(dir, "record.json"); + const error = console.error; + const log = console.log; + const said = []; + console.error = console.log = (line) => said.push(line); + try { + record(wrap(base), ["release.yml: job `test_web`"], file); + assert.equal(process.exitCode, 1); + assert.equal(fs.existsSync(file), false); + assert.match(said.join("\n"), /Not recorded: tracked jobs missing upstream/); + process.exitCode = undefined; + said.length = 0; + record(wrap(base), [], file); + assert.equal(process.exitCode, undefined); + assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), wrap(base)); + assert.match(said.join("\n"), /ci.yml: job `lint` added upstream[\s\S]*what was ported/); + } finally { + console.error = error; + console.log = log; + process.exitCode = undefined; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml index 199b43a462e8b..29063b2b940ce 100644 --- a/.github/workflows/fork-tests.yml +++ b/.github/workflows/fork-tests.yml @@ -1,9 +1,35 @@ name: Fork tests -# Upstream's release checks and tests (release.yml: quality, test, test_web, test_server) on -# GitHub-hosted runners, so a fork without upstream's runners can run them. Keep the commands in -# step with release.yml; only the runners and time limits differ. +# Fork only. Upstream's CI checks (ci.yml) on GitHub-hosted runners, because the +# fork has none of upstream's runners and upstream's workflows are disabled here. +# Commands follow ci.yml step for step. Only the runners, time limits, apt +# setup and the task cache differ (kept with actions/cache instead of upstream's +# private remote cache), and Test and Test Web split over two runners each. +# Upstream's composite actions and change detector (.github/actions, +# .github/scripts/ci-changes.cjs) are used as they are, so a rebase brings their +# changes. The `workflows` job lints the fork workflows (actionlint) and fails +# when upstream's ci.yml or release.yml test jobs change and this copy did not +# follow (.github/scripts/fork-tests-drift.cjs). +# +# Require only `fork-ci-result` in a ruleset: it needs every other job, so jobs +# can be added, split or resharded without touching the ruleset. +# +# Runners: GitHub-hosted only, Ubuntu 24.04 like upstream's runners, so an OS +# upgrade is a reviewed change here, not a surprise from ubuntu-latest. Never +# add a self-hosted runner to this workflow: this repository is public, so a +# pull request from any fork would run its code on that machine. +# +# Two modes: +# - pull_request / push to main: CI. Jobs a change cannot affect are skipped by +# upstream's detector; every skip is named with its reason in the run summary. +# - workflow_call / workflow_dispatch with `ref`: release. Every Linux job runs +# on that commit, no path filter. Covers release.yml's quality, test, test_web +# and test_server (ci.yml checks a superset of them). on: + pull_request: + push: + branches: + - main workflow_call: inputs: ref: @@ -13,120 +39,908 @@ on: workflow_dispatch: inputs: ref: - description: Commit or branch to test + description: Commit or branch to test (resolved to one commit for every job) required: true type: string permissions: contents: read +# A new push to a pull request cancels its older run. Main and release runs finish. +# The event name keeps a release's workflow_call apart from its caller's group. +concurrency: + group: fork-tests-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - quality: - name: Quality checks - runs-on: ubuntu-latest - timeout-minutes: 40 + changes: + name: Changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + # Release runs: the commit every job checks out. Empty on pull_request and + # push, where each checkout already takes the run's own commit. + sha: ${{ steps.resolve.outputs.sha }} + typecheck: ${{ steps.release.outputs.typecheck || steps.detect.outputs.typecheck }} + build: ${{ steps.release.outputs.build || steps.detect.outputs.build }} + desktop_launch: ${{ steps.release.outputs.desktop_launch || steps.detect.outputs.desktop_launch }} + test: ${{ steps.release.outputs.test || steps.detect.outputs.test }} + test_packages: ${{ steps.release.outputs.test_packages || steps.detect.outputs.test_packages }} + test_web: ${{ steps.release.outputs.test_web || steps.detect.outputs.test_web }} + test_server: ${{ steps.release.outputs.test_server || steps.detect.outputs.test_server }} + rust: ${{ steps.release.outputs.rust || steps.detect.outputs.rust }} + release_smoke: ${{ steps.release.outputs.release_smoke || steps.detect.outputs.release_smoke }} + mobile_native: ${{ steps.release.outputs.mobile_native || steps.fork.outputs.mobile_native }} steps: - name: Checkout - uses: actions/checkout@v6 + if: inputs.ref == '' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + sparse-checkout: | + /pnpm-workspace.yaml + /.github/scripts/ci-changes.cjs + /.github/actions/ci-changes/ + /*/package.json + /*/*/package.json + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Detect affected jobs + id: detect + if: inputs.ref == '' + uses: ./.github/actions/ci-changes + + # ci-changes.cjs runs the macOS lint when upstream's ci.yml changes; the + # fork's copy of it counts the same. + - name: Fork workflow changes run the macOS lint + id: fork + if: inputs.ref == '' + env: + DETECTED: ${{ steps.detect.outputs.mobile_native }} + run: | + value="$DETECTED" + if grep -qxE '\.github/workflows/fork-tests\.yml' "$RUNNER_TEMP/changed-paths" 2>/dev/null; then + value=true + fi + echo "mobile_native=$value" >> "$GITHUB_OUTPUT" + + # A branch name is resolved once, here: jobs start minutes apart, and a + # branch that moves in between would otherwise test two commits in one run. + - name: Checkout the commit to test + if: inputs.ref != '' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ inputs.ref }} + sparse-checkout: | + /package.json + sparse-checkout-cone-mode: false + + - name: Resolve the commit to test + id: resolve + if: inputs.ref != '' + env: + REF: ${{ inputs.ref }} + run: | + sha=$(git rev-parse HEAD) + echo "Testing $sha (from \`$REF\`) in every job." + echo "Release run: every job tests \`$sha\` (from \`$REF\`)." >> "$GITHUB_STEP_SUMMARY" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + # Release runs test the whole commit. upstream's release.yml has no macOS lint. + - name: Release run checks everything + id: release + if: inputs.ref != '' + run: | + for gate in typecheck build desktop_launch test test_web test_server rust release_smoke; do + echo "$gate=true" >> "$GITHUB_OUTPUT" + done + echo "test_packages=" >> "$GITHUB_OUTPUT" + echo "mobile_native=false" >> "$GITHUB_OUTPUT" + + # A broken fork workflow is red on its own pull request, and the fork copy + # must follow upstream's test jobs: the drift check lists every job and step + # upstream changed since the copy was last recorded. + workflows: + name: Workflow lint and upstream drift + needs: changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + # scripts/: some tests are run by a script there (.github/fork-ci-tests.txt). + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.changes.outputs.sha }} + sparse-checkout: | + /.github/ + /scripts/ + sparse-checkout-cone-mode: false + + # Pinned release, verified by checksum. Upstream's own workflows use runner + # labels this fork does not have, so only the fork's workflows are linted. + - name: Lint fork workflows (actionlint) + env: + VERSION: 1.7.12 + SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + archive="actionlint_${VERSION}_linux_amd64.tar.gz" + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${VERSION}/${archive}" + echo "$SHA256 $RUNNER_TEMP/$archive" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color .github/workflows/fork-*.yml + + # Every test under .github/scripts, checked against .github/fork-ci-tests.txt: + # the fork's tests run here, the others must be named by the job or script + # that runs them. An unlisted test, or a listed one deleted from history, + # fails; a listed one whose pull request has not merged yet is a note. + - name: Fork script tests + run: | + if compgen -G '.github/scripts/fork-*.test.py' >/dev/null && ! python3 -c 'import yaml' 2>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq python3-yaml + fi + node .github/scripts/fork-ci-tests.cjs + + - name: Compare upstream's test jobs with the recorded copy + run: node .github/scripts/fork-tests-drift.cjs + + # Every commit a pull request or a push to main adds, history included: a + # secret committed and then deleted is still public. Release runs test a + # commit that already passed this on its way to main, so they skip it. + # The ranges, and why: .github/scripts/fork-secret-scan.sh. + secrets: + name: Secret scan + if: ${{ inputs.ref == '' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + # Full history without file contents. The scan script fetches the contents + # of only the scanned commits (a failed fetch fails the job), then runs + # gitleaks offline. + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + filter: blob:none + + - name: Install gitleaks + env: + VERSION: 8.30.1 + SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + run: | + archive="gitleaks_${VERSION}_linux_x64.tar.gz" + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$archive" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/${archive}" + echo "$SHA256 $RUNNER_TEMP/$archive" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" gitleaks + + # The range rules against this gitleaks: a key added and deleted again + # before a merge of main, a key only a merge commit adds, push ranges. + - name: Test the scan ranges + env: + GITLEAKS_BIN: ${{ runner.temp }}/gitleaks + run: bash .github/scripts/fork-secret-scan.test.sh + + - name: Scan the added commits + env: + GITLEAKS: ${{ runner.temp }}/gitleaks + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: bash .github/scripts/fork-secret-scan.sh + + lint: + name: Lint + needs: changes + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.changes.outputs.sha }} sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - - name: Setup dependencies + - name: Reject repository-owned PR assets + run: | + files="$(git ls-files .github/pr-assets)" + if test -n "$files"; then + printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2 + exit 1 + fi + + - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + - name: Cache Electron download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - name: Check unused code + run: vp run knip:check + + - name: Check generated GitHub GraphQL operations + run: vp run --filter @t3tools/source-control-github generate --check + + - name: Check generated Expo GraphQL operations + run: vp run --filter t3 generate --check + - name: Check run: vp check + build: + name: Build and Typecheck + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.changes.outputs.sha }} + sparse-checkout: | + /* + !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a + sparse-checkout-cone-mode: false + + # Runs in the background while Vite+ installs; the step before the build waits for it. + # Xvfb is for the desktop launch check below. + - name: Start installing browser secret helper build libraries + if: needs.changes.outputs.build != 'false' + run: | + ( + set +e + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config xvfb + echo "$?" > "$RUNNER_TEMP/apt-status" + ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & + + - name: Setup Vite+ + uses: ./.github/actions/setup-ci-deps + with: + # Lint saves the pnpm store; the others only restore it. + save-pnpm-store: false + + - name: Cache Electron download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + + - name: Ensure Electron runtime is installed + id: electron + run: vp run --filter @t3tools/desktop ensure:electron + + # Upstream reads a private remote task cache (a secret the fork has not + # got). Here vp's local task cache is kept between runs instead: vp + # fingerprints each task's inputs, so a restored entry only replays a + # task whose inputs are unchanged. A new lockfile starts a fresh cache. + - name: Cache vp tasks + if: needs.changes.outputs.typecheck != 'false' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: node_modules/.vite/task-cache + key: vp-tasks-v1-typecheck-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }}-${{ github.sha }} + restore-keys: vp-tasks-v1-typecheck-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }}- + + # Runs in the background while the build runs, and reports in the last + # step: neither result hides the other. + - name: Start typecheck + id: typecheck + if: ${{ !cancelled() && steps.electron.outcome == 'success' && needs.changes.outputs.typecheck != 'false' }} + run: | + ( + set +e + vpr typecheck + echo "$?" > "$RUNNER_TEMP/typecheck-status" + ) > "$RUNNER_TEMP/typecheck.log" 2>&1 < /dev/null & + + - name: Finish installing browser secret helper build libraries + id: apt + if: ${{ !cancelled() && needs.changes.outputs.build != 'false' }} + timeout-minutes: 5 + run: | + until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done + cat "$RUNNER_TEMP/apt.log" + exit "$(cat "$RUNNER_TEMP/apt-status")" + + - name: Build desktop pipeline + id: build + if: ${{ !cancelled() && steps.electron.outcome == 'success' && steps.apt.outcome == 'success' && needs.changes.outputs.build != 'false' }} + run: vp run build:desktop + + - name: Verify preload bundle output + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node apps/desktop/scripts/verify-preload-bundle.mjs + + - name: Open the seeded thread in the built desktop app + id: desktop_launch + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.desktop_launch != 'false' }} + timeout-minutes: 4 + run: | + electron=$(node -p "require('./apps/desktop/node_modules/electron')") + xvfb-run -a node scripts/smoke-desktop-app.ts --executable "$electron" --app apps/desktop \ + --artifacts-dir "$RUNNER_TEMP/desktop-launch-check" + + - name: Upload desktop launch check + if: ${{ failure() && steps.desktop_launch.outcome == 'failure' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: desktop-launch-check + path: ${{ runner.temp }}/desktop-launch-check + if-no-files-found: ignore + + - name: Resolve Playwright version + id: playwright_version + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + + - name: Install Chromium for the built web client boot check + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' }} + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi + + - name: Boot the built web client + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + timeout-minutes: 3 + run: node apps/web/scripts/verify-built-boot.ts + - name: Typecheck - run: vp run typecheck + if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }} + timeout-minutes: 10 + run: | + until test -s "$RUNNER_TEMP/typecheck-status"; do sleep 1; done + cat "$RUNNER_TEMP/typecheck.log" + exit "$(cat "$RUNNER_TEMP/typecheck-status")" + # Everything except `t3` (apps/server) and the web app, which have their own jobs. + # Upstream runs this on one large runner; one 4-CPU runner took 6 minutes. + # Two runners split it (.github/scripts/fork-test-shards.cjs): each runs a + # vitest slice of every large package and half of the others, whole. test: - name: Package tests - runs-on: ubuntu-latest - timeout-minutes: 40 + name: Test ${{ matrix.shard }} + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.changes.outputs.sha }} sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - - name: Setup dependencies + # A shard with nothing to run says so in the summary and stops here. + - name: Pick this shard's packages + id: shard + env: + TEST_PACKAGES: ${{ needs.changes.outputs.test_packages }} + run: | + work=$(node .github/scripts/fork-test-shards.cjs "${{ matrix.shard }}/${{ strategy.job-total }}") + whole=$(awk '$1 == "whole" { printf "%s ", $2 }' <<< "$work") + split=$(awk '$1 == "split" { printf "%s ", $2 }' <<< "$work") + { echo "whole=${whole% }"; echo "split=${split% }"; echo "any=$([[ -n "$work" ]] && echo true || echo false)"; } >> "$GITHUB_OUTPUT" + + # The desktop tests compile the browser secret helper. The install runs in + # the background while Vite+ installs; the step before the tests waits for it. + - name: Start installing browser secret helper build libraries + if: steps.shard.outputs.any == 'true' + run: | + ( + set +e + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config + echo "$?" > "$RUNNER_TEMP/apt-status" + ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & + + - name: Setup Vite+ + if: steps.shard.outputs.any == 'true' uses: ./.github/actions/setup-ci-deps + with: + # Lint saves the pnpm store; the others only restore it. + save-pnpm-store: false + + - name: Cache Electron download + if: steps.shard.outputs.any == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} - name: Ensure Electron runtime is installed + if: steps.shard.outputs.any == 'true' run: vp run --filter @t3tools/desktop ensure:electron - - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential + - name: Test preview artifact validation + if: matrix.shard == 1 + run: python3 -B .github/scripts/stage-preview-bundle.test.py + # Upstream's ci.yml runs the first four; the fork adds thread-transfer-report, + # which upstream runs from a workflow_run of its CI (disabled in the fork). + - name: Test release checks + if: matrix.shard == 1 + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/mobile-preview-comment.test.cjs .github/scripts/test-delta.test.cjs .github/scripts/thread-transfer-report.test.cjs + + - name: Finish installing browser secret helper build libraries + if: steps.shard.outputs.any == 'true' + timeout-minutes: 5 + run: | + until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done + cat "$RUNNER_TEMP/apt.log" + exit "$(cat "$RUNNER_TEMP/apt-status")" + + # Whole packages as upstream runs them; a large package's slice runs its + # own test script in its directory with vitest's --shard (`vp run` does + # not pass arguments through to vitest). - name: Test - run: vp run --parallel --concurrency-limit 2 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + if: steps.shard.outputs.any == 'true' + env: + WHOLE: ${{ steps.shard.outputs.whole }} + SPLIT: ${{ steps.shard.outputs.split }} + SHARD: ${{ matrix.shard }}/${{ strategy.job-total }} + run: | + set -o pipefail + # The runner's trace writer can crash on surrogate pairs in test output. + ascii() { + python3 -u -c ' + import sys + for line in sys.stdin: + sys.stdout.write(line.encode("ascii", "backslashreplace").decode("ascii")) + ' + } + status=0 + if [[ -n "$WHOLE" ]]; then + filters=() + for package in $WHOLE; do filters+=(--filter "$package"); done + vp run --parallel --concurrency-limit 4 "${filters[@]}" test 2>&1 | ascii || status=1 + fi + for dir in $SPLIT; do + echo "::group::$dir slice $SHARD" + script=$(jq -r .scripts.test "$dir/package.json") + (cd "$dir" && eval "$script --shard=$SHARD") 2>&1 | ascii || status=1 + echo "::endgroup::" + done + exit "$status" + # Upstream runs this on one large runner; on a 4-CPU runner the 548 files + # took 7.5 minutes, so two runners each take a vitest slice. test_web: - name: Web tests - runs-on: ubuntu-latest - timeout-minutes: 40 + name: Test Web ${{ matrix.shard }} + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.changes.outputs.sha }} sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - - name: Setup dependencies + - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + # Lint saves the pnpm store; the others only restore it. + save-pnpm-store: false - - name: Install Chromium for web browser tests - run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + - name: Resolve Playwright version + id: playwright_version + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" - - name: Test - run: vp run --filter @t3tools/web test + - name: Cache Chromium + id: chromium_cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} - - name: Boot the built web client + - name: Install Chromium for web browser tests + if: steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies run: | - vp run --filter @t3tools/web build - node apps/web/scripts/verify-built-boot.ts + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi + + # Upstream: `vp run --filter @t3tools/web test --shard N/total`. The same + # script, run in its directory with the slice, as on the Test shards. + # Upstream's release test_web also boots the built client on shard 1; + # here the Build job does that, for CI and release runs alike. + - name: Test + env: + SHARD: ${{ matrix.shard }}/${{ strategy.job-total }} + working-directory: apps/web + run: eval "$(jq -r .scripts.test package.json) --shard=$SHARD" + # apps/server runs its test files one at a time; shards spread them over + # separate runners. As upstream, the shards run the change detector + # themselves, so the steps follow ci.yml one for one; a shard the change + # cannot affect says so in its summary. They wait for `changes` only for a + # release run's resolved commit (seconds; every other job waits for it too). test_server: - name: Server tests ${{ matrix.shard }} - runs-on: ubuntu-latest - timeout-minutes: 30 + name: Test Server ${{ matrix.shard }} + needs: changes + runs-on: ubuntu-24.04 + timeout-minutes: 20 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.changes.outputs.sha }} + fetch-depth: 2 sparse-checkout: | /* !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - - name: Setup dependencies + - name: Detect affected jobs + id: changes + if: inputs.ref == '' + uses: ./.github/actions/ci-changes + + - name: Report skip + if: steps.changes.outputs.test_server == 'false' + run: | + msg="Skipped: upstream's change detector found no change that affects t3 (apps/server)." + echo "::notice title=Test Server ${{ matrix.shard }} skipped::$msg" + echo "$msg" >> "$GITHUB_STEP_SUMMARY" + + - name: Setup Vite+ + if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps with: - # Eight shards share one install; one saves the store. - save-pnpm-store: ${{ matrix.shard == 1 }} + # Lint saves the pnpm store; the others only restore it. + save-pnpm-store: false + + # Upstream reads a private remote task cache (a secret the fork has not + # got). Here vp's local task cache is kept between runs instead: vp + # fingerprints each task's inputs, so a restored entry only replays a + # task whose inputs are unchanged. A new lockfile starts a fresh cache. + - name: Cache vp tasks + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: node_modules/.vite/task-cache + key: vp-tasks-v1-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }}-${{ github.sha }} + restore-keys: vp-tasks-v1-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }}- + + - name: Resolve Playwright version + id: playwright_version + if: steps.changes.outputs.test_server != 'false' + run: echo "version=$(node -p "require('./apps/server/node_modules/playwright-core/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} - name: Install Chromium for server browser integration tests - run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + if: steps.changes.outputs.test_server != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/server/node_modules/playwright-core/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: steps.changes.outputs.test_server != 'false' + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/server/node_modules/playwright-core/cli.js install-deps chromium + fi + + # As upstream: Vitest's module cache, saved only by pushes to main so + # pull requests never evict each other's entries. + - name: Restore Vitest module cache + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- - name: Test - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} + if: steps.changes.outputs.test_server != 'false' + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json + run: >- + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + --uncached apps/server/integration/transferBudgetV2.integration.test.ts + --verify-hits 1 apps/server + + - name: Save Vitest module cache + if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + + # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard + # produces these files. + - name: Detect transfer budget report + id: transfer_budget + if: always() + run: | + if test -f "${{ runner.temp }}/thread-transfer-result.json"; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish transfer budget report + if: always() && steps.transfer_budget.outputs.present == 'true' + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() && steps.transfer_budget.outputs.present == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + + rust: + name: Rust + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.changes.outputs.sha }} + sparse-checkout: | + /* + !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a + sparse-checkout-cone-mode: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt + + - name: Check Rust formatting + run: | + for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do + cargo fmt --manifest-path "native/$crate/Cargo.toml" -- --check + done + + - name: Test Rust crates + run: | + for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do + cargo test --locked --manifest-path "native/$crate/Cargo.toml" + done + + # Upstream runs this on macOS 26. GitHub-hosted macOS minutes are free on a + # public repository; it runs only for native mobile sources or a workflow change. + mobile_native_static_analysis: + name: Mobile Native Static Analysis + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.changes.outputs.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Install mobile native static analysis tools + run: brew bundle install --file apps/mobile/Brewfile + + - name: Lint mobile native sources + run: vp run lint:mobile + + release_smoke: + name: Release Smoke + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.changes.outputs.sha }} + sparse-checkout: | + /* + !/.repos/ + !/apps/mobile/modules/t3-terminal/Vendor/**/*.a + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: ./.github/actions/setup-ci-deps + with: + # Lint saves the pnpm store; the others only restore it. + save-pnpm-store: false + + - name: Exercise release-only workflow steps + run: node scripts/release-smoke.ts + + # The one check a ruleset requires. Fails when any job fails or is cancelled, + # or is skipped without a reason: `changes` said its gates were not needed, + # or (secret scan) it is a release run. The summary lists every job, the + # reason for every skip, and each job's wall time. + # `always()` rather than `!cancelled()`: a skipped job reports success to + # branch protection, so a cancelled run must still produce a failing result. + fork_ci_result: + name: fork-ci-result + if: ${{ always() }} + needs: + [ + changes, + workflows, + secrets, + lint, + build, + test, + test_web, + test_server, + rust, + mobile_native_static_analysis, + release_smoke, + ] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + # The job list API needs actions: read; nothing here checks out code. + permissions: + actions: read + steps: + - name: Require every job to pass + env: + RESULTS: ${{ toJSON(needs) }} + RELEASE_RUN: ${{ inputs.ref != '' }} + run: | + echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + # A jq program: $release is jq's variable, not the shell's. + # shellcheck disable=SC2016 + why='{ + build: "the change affects neither desktop, server nor web, and no package needs a typecheck", + test: "the change affects no package this job tests", + test_web: "the change affects nothing @t3tools/web depends on", + rust: "the change touches none of the native Rust crates", + mobile_native_static_analysis: (if $release == "true" + then "release run: upstream release.yml has no macOS lint" + else "no native mobile source, Brewfile or CI workflow changed" end), + release_smoke: "the change does not affect scripts/", + secrets: "release run: the commit was scanned on its way to main" + }' + { + echo "### fork-ci-result" + echo + echo "| Job | Result | Why skipped |" + echo "| --- | --- | --- |" + echo "$RESULTS" | jq -r --arg release "$RELEASE_RUN" "$why as \$why + | to_entries[] + | \"| \(.key) | \(.value.result) | \(if .value.result == \"skipped\" then (\$why[.key] // \"no reason: fails\") else \"\" end) |\"" + if [[ "$(echo "$RESULTS" | jq -r '.changes.outputs.test_server')" == false ]]; then + echo + echo "Server shards passed without running tests: the change affects nothing t3 (apps/server) depends on." + fi + } >> "$GITHUB_STEP_SUMMARY" + # A job may skip only when `changes` said every gate it represents was + # not needed (gates are keyed by job name, except merged Build and the + # macOS lint), or, for the secret scan, in a release run. + echo "$RESULTS" | jq -e --arg release "$RELEASE_RUN" '.changes.outputs as $gates + | {build: ["build", "typecheck", "desktop_launch"], mobile_native_static_analysis: ["mobile_native"]} as $renamed + | all(to_entries[]; + .value.result == "success" + or (.value.result == "skipped" + and (if .key == "secrets" then $release == "true" + else (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false")) end)))' > /dev/null + + # A run whose server tests ran must have produced the transfer measurements. + - name: Require transfer measurements + if: needs.test_server.result == 'success' && needs.changes.outputs.test_server != 'false' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer + + # Information only: read from the run's job list, never fails the run. + - name: Job wall times + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + jobs=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=100") || { + echo "::warning::Job timings unavailable: the job list API call failed." + echo "Job timings unavailable." >> "$GITHUB_STEP_SUMMARY" + exit 0 + } + echo "Job timings for $(echo "$jobs" | jq '.jobs | length') jobs." + { + echo + echo "#### Job wall times" + echo + echo "| Job | Queued | Ran |" + echo "| --- | ---: | ---: |" + echo "$jobs" | jq -r '.jobs[] + | select(.completed_at != null and .started_at != null) + | ((.started_at | fromdateiso8601) - (.created_at | fromdateiso8601)) as $queued + | ((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)) as $ran + | "| \(.name) | \($queued)s | \($ran / 60 | floor)m \($ran % 60)s |"' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/lib/release-repository.test.ts b/scripts/release-repository.test.ts similarity index 96% rename from scripts/lib/release-repository.test.ts rename to scripts/release-repository.test.ts index 46a285b29425e..fe17b731d0303 100644 --- a/scripts/lib/release-repository.test.ts +++ b/scripts/release-repository.test.ts @@ -3,8 +3,8 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; -import { resolveGitHubPublishConfig } from "../build-desktop-artifact.ts"; -import { releaseRepositoryDefine, resolveReleaseRepository } from "./release-repository.ts"; +import { resolveGitHubPublishConfig } from "./build-desktop-artifact.ts"; +import { releaseRepositoryDefine, resolveReleaseRepository } from "./lib/release-repository.ts"; const desktopFeedRepository = (env: Record) => resolveGitHubPublishConfig("latest").pipe( From ae0d6865eb2f2e49a733a58c24accc23adb9fc11 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:04:49 +0100 Subject: [PATCH 04/27] fork: CI check that refuses database schema and migration changes (#3) * fork: CI check that refuses database schema and migration changes T3 has no down-migrations, so a fork patch that changes the schema would strand every install that later returns to upstream. fork-checks.yml runs on every pull request and fails when the change touches the migration files or adds a schema statement to shipped source. The follow workflow runs the same script over the whole patch series. Co-Authored-By: Claude Opus 5.5 * fork: schema check runs from the base branch and reads whole files Review round 1 of the schema check: - The workflow runs on pull_request_target, so its file and the check script come from the default branch; a pull request can no longer weaken, skip or delete the check that judges it. The pull request's commits are only fetched and read as blobs, never checked out or run. - Changes to the check's own files or to any .gitattributes are refused once the base has the check; only the owner can merge such a change. - Files the migrations import (transitively, computed from both trees) count as schema files, and a shipped file whose text holds a schema statement before or after the change is refused; statements match across lines. - Paths come from `git diff --raw -z` and texts from the blobs, so quoted non-ASCII paths and .gitattributes cannot hide a change. Co-Authored-By: Claude Opus 5.5 * fork: schema check judges its own files against the base's copy - A check file or .gitattributes that the branch carries unchanged from the base passes. After main is rebased onto a new upstream release, a branch from the old main is told to rebase onto main (its commits are matched on main by author, author time and subject), never to merge past the red check. - Package root imports (@t3tools/contracts) are followed to the files that declare the imported names, so migration 064's schemas in threadPullRequest.ts are covered while the rest of the package is not. - Schema statements also match across SQL comments and string joins, and with upper-case keywords around an interpolation (CREATE ${kind}). - parseRawDiff throws on output it cannot read; actions/checkout is pinned by commit SHA. Co-Authored-By: Claude Opus 5.5 * fork: schema check guards re-exporting barrels and fails closed on untraced names A name imported from a package root that no file is seen to declare now guards the whole barrel and everything it re-exports, instead of dropping out of the guarded set. The tracer also knows export async function, a local export { type X }, export { default as X } from, and destructured exports. The barrels on the way to a declaring file are guarded too, so a barrel-only edit cannot re-point a name a migration imports. On a branch holding commits already on main, a real edit to a check file is reported as one, and the message no longer assumes main was rebased. Co-Authored-By: Claude Opus 5.5 * fork: schema check guards package.json and refuses imports it cannot resolve - The package.json of every workspace package that schema code imports is guarded, so an edit that re-points such an import (another export target, a condition Node picks first, exports removed in favour of main) is refused. Every file any export condition can pick is guarded, and main, module and types are followed when a package has no exports. - An import that should resolve inside the repository but does not is refused, naming the import: a workspace package with no entry file, a relative import of a missing file, a #name import, a computed import() or require(), import.meta.glob. Only packages outside the workspace resolve to nothing; the output lists them. - Workspace packages come from the pnpm workspace list (scoped or not, at any depth), and a change to that list is refused. A test-path file that schema code imports is guarded like any other. - The tracer no longer counts the key in `export const { S: renamed }` as declaring S. Co-Authored-By: Claude Opus 5.5 * fork: schema check guards the importer's dependency specs and judges only new unfollowable imports - The dependency specs of every name schema code imports (workspace packages and outside names such as effect) are compared in the package.json files of the packages holding schema code and the root one, plus pnpm-workspace.yaml catalog/catalogs/overrides/patchedDependencies and the root pnpm.overrides/patchedDependencies/resolutions. Patch files of those names are guarded. Other dependency changes stay free. - A package.json is judged by the fields that map the imports schema code makes through it (name, type, main/module/types/browser, imports, the export entries answering those keys), so adding a module to a package stays free. - An import the check cannot follow is refused only when the branch brings it; ones already on the merge-base are listed, not refused. Imports are read from code with comments and string contents blanked, so prose like "require (in order)" is not an import. - Tests: the browser fallback, the four importer-side probes and more, manifest field judging, the lexer and the merge-base rule. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: t3code-fork Co-authored-by: Claude Opus 5.5 --- .github/scripts/fork-schema-check.cjs | 1064 +++++++++++++++++++ .github/scripts/fork-schema-check.test.cjs | 1103 ++++++++++++++++++++ .github/workflows/fork-checks.yml | 44 + 3 files changed, 2211 insertions(+) create mode 100644 .github/scripts/fork-schema-check.cjs create mode 100644 .github/scripts/fork-schema-check.test.cjs create mode 100644 .github/workflows/fork-checks.yml diff --git a/.github/scripts/fork-schema-check.cjs b/.github/scripts/fork-schema-check.cjs new file mode 100644 index 0000000000000..3f98dbcf40a34 --- /dev/null +++ b/.github/scripts/fork-schema-check.cjs @@ -0,0 +1,1064 @@ +// Refuses fork changes to the database schema: T3 has no down-migrations, so a fork migration +// strands installs that return to upstream. Usage: fork-schema-check.cjs . +// Refused: migrations, schema setup and what they import; a change to how those imports map to +// files (the package.json fields that map them, the dependency specs, catalogs and overrides of +// the imported names, the workspace package list); a new import of theirs that should resolve +// inside the repository but does not; shipped source files holding a schema statement; a PR copy +// of this check or of .gitattributes that differs from the base's copy; and a branch holding +// commits already on the base (built on an old, since rebased base, or copying its commits). +// Files are read as git blobs. + +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +// Files that define or apply the schema. Any change to them, or to what they import, is refused. +const SCHEMA_ROOTS = [ + /^apps\/server\/src\/persistence\/Migrations(\/|\.ts$)/, + /^apps\/server\/src\/persistence\/databaseMigrationHead\.ts$/, + /^apps\/server\/src\/persistence\/initializeV2Database\.ts$/, + /^apps\/server\/src\/persistence\/reconcileV2PreviewMigration\.ts$/, +]; + +// The check's own files. Changing them would let a PR judge itself, so it is never allowed. +const CHECK_SCRIPT = ".github/scripts/fork-schema-check.cjs"; +const CHECK_FILES = [ + CHECK_SCRIPT, + ".github/scripts/fork-schema-check.test.cjs", + ".github/workflows/fork-checks.yml", +]; +const GITATTRIBUTES = /(^|\/)\.gitattributes$/; + +// Schema statements in shipped source (tests may build their own tables). Between the keywords +// may stand line breaks, SQL comments and string joins ("CREATE " + "TABLE"). An interpolation +// where the object kind belongs (`CREATE ${kind}`) counts too, but only with upper-case keywords, +// so UI text such as `Create ${label}` stays free. +const GAP = String.raw`(?:\s|["'\x60+]|/\*[\s\S]*?\*/|--[^\n]*\n)+`; +const HOLE = String.raw`\$\{[^}]*\}`; +const SCHEMA_STATEMENTS = [ + new RegExp( + String.raw`\b(?:CREATE${GAP}(?:UNIQUE${GAP})?(?:TABLE|INDEX|VIEW|TRIGGER|VIRTUAL${GAP}TABLE)|ALTER${GAP}TABLE|DROP${GAP}(?:TABLE|INDEX|VIEW|TRIGGER))\b`, + "i", + ), + new RegExp(String.raw`\b(?:CREATE${GAP}(?:UNIQUE${GAP})?|ALTER${GAP}|DROP${GAP})${HOLE}`), +]; +/** The first schema statement in `text`, or undefined. */ +const schemaStatement = (text) => + text === undefined + ? undefined + : SCHEMA_STATEMENTS.map((pattern) => text.match(pattern)?.[0]).find(Boolean); +const SHIPPED_SOURCE = /^(apps|packages)\/.+\.(ts|tsx|mts|cts|js|mjs|cjs|sql)$/; +const TEST_SOURCE = /(\.test\.|\.spec\.|\.testkit\.|\/testkit\/|\/__tests__\/|\/test\/)/; +const SOURCE_EXTENSIONS = ["", ".ts", ".tsx", ".mts", ".js", "/index.ts", "/index.tsx"]; +// Files whose imports the closure follows; other files it reaches (JSON, SQL) are guarded whole. +const SCRIPT_SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/; +const MANIFEST = /(^|\/)package\.json$/; +const WORKSPACE_FILE = "pnpm-workspace.yaml"; +// These run on code whose comments are removed and whose string contents are blanked (see lex), so +// prose such as `require (in order)` in a comment or a string is not taken for an import; the +// specifier itself is then read back from the same offsets of the text with its strings intact. +const IMPORT_SPECIFIER = /\b(?:from|import|require)\s*\(?\s*["']([^"'\n]+)["']/dg; +// An import whose target is computed (`import(name)`, a template, `import.meta.glob`): not followed. +const COMPUTED_IMPORT = /\b(?:import|require)\s*\(\s*(?!["'\s])|\bimport\.meta\.glob\b/g; +// `import { A, type B as C } from "x"` and `export { A } from "x"`: the names a static clause takes. +const IMPORT_CLAUSE = + /\b(?:import|export)\s+(?:type\s+)?([\w$*{}\s,]*?)\s*from\s*["']([^"'\n]+)["']/dg; +const REEXPORT = + /\bexport\s+(?:type\s+)?(\*(?:\s+as\s+([\w$]+))?|\{[^}]*\})\s*from\s*["']([^"'\n]+)["']/dg; +const DEPENDENCY_FIELDS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; + +const isTest = (file) => TEST_SOURCE.test(file); +const isShippedSource = (file) => SHIPPED_SOURCE.test(file) && !isTest(file); + +/** + * Files the schema roots import, transitively: relative imports, and for an import of a workspace + * package every file any export condition of that entry can pick. For a package root + * (`@t3tools/contracts`) only the files that declare the imported names are followed. The barrels + * on the way to a declaring file are guarded too (a barrel edit could re-point a name), but their + * other re-exports are not followed, so the rest of the package stays free to change. A name that + * cannot be traced guards the whole barrel and everything it re-exports. + * + * Besides the files: `mappings` names, per package.json, the entries (`.`, `./sub`) the schema + * code imports through it (only those fields are compared, so a new export elsewhere stays free); + * `imported` holds every package name the schema code imports, from the workspace or outside, and + * `holders` the package.json files of the packages holding schema code, plus the root one: their + * dependency specs for those names decide what the names resolve to. + * + * Nothing that could be schema code is skipped silently: an import inside the repository that + * resolves to no file is listed in `unresolved` (the check refuses it when the PR brings it), and + * only packages outside the workspace resolve to nothing; their names are listed in `outside`. + * @param {{ files: string[]; has(file: string): boolean; read(file: string): string }} tree + * @returns {{ closure: Set; unresolved: string[]; outside: Set; + * imported: Set; mappings: Map>; holders: Set }} + */ +function traceSchema(tree) { + const queue = tree.files.filter( + (file) => SCHEMA_ROOTS.some((pattern) => pattern.test(file)) && !isTest(file), + ); + const closure = new Set(queue); + const unresolved = new Map(); + const mappings = new Map(); + const workspace = workspacePackages(tree); + const ctx = { + tree, + ...workspace, + outside: new Set(), + imported: new Set(), + map: (file, key) => mappings.set(file, (mappings.get(file) ?? new Set()).add(key)), + unresolved: (from, specifier, why) => + unresolved.set(`${from}\0${specifier}`, `${from}: imports "${specifier}", but ${why}.`), + }; + const walked = new Set(queue); + while (queue.length > 0) { + const file = queue.pop(); + if (!SCRIPT_SOURCE.test(file)) continue; + const source = lexed(tree.read(file)); + if (source.open) { + unresolved.set( + `${file}\0`, + `${file}: a comment, string or template in it does not end, so its imports cannot be read.`, + ); + } + const names = importedNames(source); + for (const match of source.code.matchAll(COMPUTED_IMPORT)) { + ctx.unresolved( + file, + `${match[0].trim()}...`, + "its target is computed, so it cannot be followed", + ); + } + for (const match of source.code.matchAll(IMPORT_SPECIFIER)) { + const specifier = source.group(match, 1); + const reached = resolveImport(ctx, file, specifier, names.get(specifier)); + // A test-path file that schema code imports ships with it, so it is guarded too. + for (const target of reached.barrels) closure.add(target); + for (const target of reached.files) { + if (!walked.has(target)) { + closure.add(target); + walked.add(target); + queue.push(target); + } + } + } + } + const why = + "the check cannot resolve it to a file in this repository, so it cannot tell which files " + + "the schema depends on. Fix the import (or teach the check this import shape)"; + // The package holding a file is its deepest workspace member directory. + const holders = new Set(tree.has("package.json") ? ["package.json"] : []); + for (const file of closure) { + const holder = workspace.members + .filter((member) => member.dir !== "" && file.startsWith(`${member.dir}/`)) + .sort((a, b) => b.dir.length - a.dir.length)[0]; + if (holder) holders.add(holder.file); + } + return { + closure, + unresolved: [...unresolved.values()].map((reason) => reason.replace(/\.$/, `: ${why}.`)), + outside: ctx.outside, + imported: ctx.imported, + mappings, + holders, + }; +} + +/** + * The text with comments and the contents of strings, templates and regular expressions blanked + * (`code`), at the same offsets as the text. `open` says the text ends inside a block comment or a + * template. `group(match, n)` reads a match's group `n` (found in `code`, `d` flag) from the text, + * where strings are intact. + */ +function lex(text) { + const code = text.split(""); + const blank = (array, from, to) => { + for (let k = from; k < Math.min(to, text.length); k += 1) { + if (array[k] !== "\n") array[k] = " "; + } + }; + const templates = []; // the brace depth at each open `${` + let depth = 0; + let prev = ""; // the last code token: a punctuation character or a word + let open = false; + let i = 0; + // From just after a backtick, or the `}` that closes an interpolation, to the next `${` or `. + const template = () => { + const start = i; + while (i < text.length) { + if (text[i] === "\\") i += 2; + else if (text[i] === "`") { + blank(code, start, i); + i += 1; + prev = ")"; + return; + } else if (text[i] === "$" && text[i + 1] === "{") { + blank(code, start, i); + i += 2; + templates.push(depth); + depth += 1; + prev = "{"; + return; + } else i += 1; + } + blank(code, start, text.length); + open = true; + }; + while (i < text.length) { + const c = text[i]; + if (c === "/" && text[i + 1] === "/") { + const end = text.indexOf("\n", i); + const stop = end < 0 ? text.length : end; + blank(code, i, stop); + i = stop; + } else if (c === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + if (end < 0) open = true; + const stop = end < 0 ? text.length : end + 2; + blank(code, i, stop); + i = stop; + } else if (c === "'" || c === '"' || (c === "/" && REGEX_AFTER.has(prev))) { + // A string or a regular expression literal: both end at the line's end at the latest. + let j = i + 1; + let inClass = false; + while (j < text.length && text[j] !== "\n") { + if (text[j] === "\\") j += 1; + else if (c === "/" && text[j] === "[") inClass = true; + else if (c === "/" && text[j] === "]") inClass = false; + else if (text[j] === c && !inClass) break; + j += 1; + } + blank(code, i + 1, j); + i = j + 1; + prev = ")"; + } else if (c === "`") { + i += 1; + template(); + } else if (c === "}" && templates.at(-1) === depth - 1) { + templates.pop(); + depth -= 1; + i += 1; + template(); + } else if (/[\w$]/.test(c)) { + const word = text.slice(i).match(/^[\w$]+/)[0]; + prev = word; + i += word.length; + } else { + if (c === "{") depth += 1; + if (c === "}") depth -= 1; + if (!/\s/.test(c)) prev = c; + i += 1; + } + } + if (templates.length > 0) open = true; + return { + code: code.join(""), + open, + group: (match, n) => match.indices[n] && text.slice(...match.indices[n]), + }; +} +// After these tokens a `/` starts a regular expression, not a division. +const REGEX_AFTER = new Set([ + ..."(,=:[!&|?{};+-*%<>~^", + "", + "return", + "typeof", + "case", + "in", + "of", + "new", + "delete", + "void", + "throw", + "yield", + "await", + "else", + "do", +]); +const lexCache = new Map(); +function lexed(text) { + if (!lexCache.has(text)) lexCache.set(text, lex(text)); + return lexCache.get(text); +} + +/** The files the schema roots import, transitively (see traceSchema). */ +const schemaClosure = (tree) => traceSchema(tree).closure; + +/** + * The workspace's member directories as written in pnpm-workspace.yaml `packages:`, or else the + * root package.json `workspaces`; undefined when neither says (then every package.json counts). + * @returns {{ source?: string; patterns?: string[] }} + */ +function workspacePatterns(tree) { + if (tree.has(WORKSPACE_FILE)) { + const lines = tree.read(WORKSPACE_FILE).split("\n"); + const start = lines.findIndex((line) => /^packages\s*:/.test(line)); + if (start < 0) return { source: WORKSPACE_FILE }; + const inline = lines[start].match(/^packages\s*:\s*\[(.*)\]/); + const items = inline + ? inline[1].split(",") + : lines + .slice(start + 1) + .filter((line, index, rest) => rest.slice(0, index + 1).every((l) => /^(\s|#|$)/.test(l))) + .map((line) => line.match(/^\s+-\s*(.*)$/)?.[1]) + .filter((item) => item !== undefined); + return { + source: WORKSPACE_FILE, + patterns: items + .map((item) => + item + .replace(/\s+#.*$/, "") + .trim() + .replace(/^["']|["']$/g, ""), + ) + .filter(Boolean), + }; + } + try { + const { workspaces } = JSON.parse(tree.read("package.json")); + const patterns = Array.isArray(workspaces) ? workspaces : workspaces?.packages; + if (Array.isArray(patterns)) return { source: "package.json", patterns }; + } catch { + // No root manifest, or not JSON: no workspace list, so every package.json counts. + } + return {}; +} + +/** + * Workspace packages by name. A name may have several directories (all are consulted). Manifests + * that are not JSON are listed in `broken`: such a package's name is unknown, so an import that + * matches no package is then not trusted to be from outside the repository. + */ +function workspacePackages(tree) { + const { patterns } = workspacePatterns(tree); + const glob = (pattern) => + new RegExp( + `^${pattern + .replace(/^\.\//, "") + .replace(/\/$/, "") + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace( + /\*\*\/|\*\*|\*/g, + (star) => ({ "**/": "(?:.*/)?", "**": ".*" })[star] ?? "[^/]*", + )}$`, + ); + const include = patterns?.filter((pattern) => !pattern.startsWith("!")).map(glob); + const exclude = patterns + ?.filter((pattern) => pattern.startsWith("!")) + .map((p) => glob(p.slice(1))); + const member = (dir) => + include + ? dir === "" || (include.some((re) => re.test(dir)) && !exclude.some((re) => re.test(dir))) + : !/(^|\/)node_modules(\/|$)/.test(dir); + const packages = new Map(); + const broken = []; + const members = []; + for (const file of tree.files) { + if (!MANIFEST.test(file)) continue; + const dir = file === "package.json" ? "" : path.posix.dirname(file); + if (!member(dir)) continue; + members.push({ dir, file }); + let manifest; + try { + manifest = JSON.parse(tree.read(file)); + } catch { + broken.push(file); + continue; + } + if (typeof manifest?.name !== "string") continue; + const list = packages.get(manifest.name) ?? []; + list.push({ dir, file, manifest }); + packages.set(manifest.name, list); + } + return { packages, broken, members }; +} + +/** + * Per specifier, the names static clauses take from it; `*` for a namespace or a clause it cannot + * read. A specifier with no entry (dynamic import, require, side-effect import) takes everything. + * @param {ReturnType} source + * @returns {Map>} + */ +function importedNames(source) { + const names = new Map(); + for (const match of source.code.matchAll(IMPORT_CLAUSE)) { + const clause = match[1]; + const specifier = source.group(match, 2); + const set = names.get(specifier) ?? new Set(); + names.set(specifier, set); + const braces = clause.match(/\{([^}]*)\}/); + const outside = clause + .replace(/\{[^}]*\}/, "") + .replace(/,/g, " ") + .trim(); + if (/\*/.test(outside)) set.add("*"); + else if (outside) set.add("default"); + for (const part of braces ? braces[1].split(",") : []) { + const name = part + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0] + .trim(); + if (name) set.add(name); + } + if (!braces && !outside) set.add("*"); + } + return names; +} + +/** + * The files an import reaches: the files it maps to, or for a package root the files declaring + * `names` (`files`, whose own imports matter) and the barrels re-exporting them (`barrels`, guarded + * only). A workspace package's package.json is guarded through `ctx.guard`; an import that should + * resolve inside the repository but does not is reported through `ctx.unresolved`. + * @returns {{ files: string[]; barrels: string[] }} + */ +function resolveImport(ctx, from, specifier, names) { + const { tree } = ctx; + const none = { files: [], barrels: [] }; + if (/^\.\.?(\/|$)/.test(specifier)) { + const target = resolveFile(tree, path.posix.join(path.posix.dirname(from), specifier)); + if (target) return { files: [target], barrels: [] }; + ctx.unresolved(from, specifier, "no file in the repository matches it"); + return none; + } + if (/^[a-z][\w+.-]*:/i.test(specifier)) { + // `node:fs` and other scheme imports come from the runtime, not from this repository. + ctx.outside.add(specifier); + return none; + } + // `#name` (package.json "imports") is not traced, so it is reported like any unresolved import. + const parts = specifier.match(/^((?:@[^/@]+\/)?[^/@.#][^/]*)(?:\/(.+))?$/); + if (!parts) { + ctx.unresolved(from, specifier, "it is neither a relative import nor a package name"); + return none; + } + const [, name, subpath] = parts; + ctx.imported.add(name); + const candidates = ctx.packages.get(name); + if (!candidates) { + if (ctx.broken.length > 0) { + ctx.unresolved( + from, + specifier, + `${ctx.broken.join(", ")} is not JSON, so its name is unknown`, + ); + } else { + ctx.outside.add(name); + } + return none; + } + const entries = []; + for (const pkg of candidates) { + ctx.map(pkg.file, subpath ? `./${subpath}` : "."); + for (const target of packageTargets(pkg.manifest, subpath)) { + const file = resolveFile(tree, path.posix.join(pkg.dir, target)); + if (file && !entries.includes(file)) entries.push(file); + } + } + if (entries.length === 0) { + ctx.unresolved( + from, + specifier, + `${name} is a package of this repository (${candidates.map((pkg) => pkg.file).join(", ")}) ` + + "and none of the entry points it lists for that import is a file here", + ); + return none; + } + if (subpath || !names || names.has("*") || names.has("default")) + return { files: entries, barrels: [] }; + const files = new Set(); + const barrels = new Set(); + for (const entry of entries) { + const found = new Set(); + // A name no file is seen to declare fails closed: that entry file is walked whole. + const traced = [...names].every((each) => + declaringFiles(ctx, entry, each, found, barrels, new Map()), + ); + for (const file of traced ? found : [entry]) files.add(file); + } + return { files: [...files], barrels: [...barrels] }; +} + +/** + * Every path a package.json can map `name[/subpath]` to, for any export condition (`node`, + * `import`, `types`, `default`, custom ones, nested or in fallback arrays): the check does not + * guess which condition a tool picks, so it guards them all. Without `exports`: `main`, `module`, + * `types`, a string `browser` and `index`, or the subpath itself. + * @returns {string[]} + */ +function packageTargets(manifest, subpath) { + const key = subpath ? `./${subpath}` : "."; + const { exports } = manifest; + if (exports === undefined || exports === null) { + if (subpath) return [key]; + return [ + manifest.main, + manifest.module, + manifest.types, + manifest.typings, + manifest.browser, + "./index", + ].filter((target) => typeof target === "string"); + } + const targets = []; + const leaves = (value, star) => { + if (typeof value === "string") + targets.push(star === undefined ? value : value.replaceAll("*", star)); + else if (Array.isArray(value)) for (const each of value) leaves(each, star); + else if (value && typeof value === "object") + for (const each of Object.values(value)) leaves(each, star); + }; + for (const [, value, star] of exportEntries(exports, key)) leaves(value, star); + return targets; +} + +/** + * The `exports` entries that can answer `key`: the exact key, or else every `*` pattern that + * matches it, as [pattern, value, what the star stands for]. + */ +function exportEntries(exports, key) { + const map = + typeof exports === "object" && + !Array.isArray(exports) && + Object.keys(exports).some((each) => each.startsWith(".")) + ? exports + : { ".": exports }; + if (Object.hasOwn(map, key)) return [[key, map[key], undefined]]; + const entries = []; + for (const [pattern, value] of Object.entries(map)) { + const star = pattern.indexOf("*"); + if (star < 0) continue; + const [prefix, suffix] = [pattern.slice(0, star), pattern.slice(star + 1)]; + if (key.length >= pattern.length - 1 && key.startsWith(prefix) && key.endsWith(suffix)) { + entries.push([pattern, value, key.slice(prefix.length, key.length - suffix.length)]); + } + } + return entries; +} + +/** + * What a package.json says about the import `key` (`.` or `./sub`): its name, type, the export + * entries that answer that key (and whether `exports` is a subpath map), the fallback fields and + * `imports`. Two trees whose values differ map the import differently; other export entries do not + * count, so adding a module to the package stays free. A file that is absent or not JSON gives + * a value of its own. + */ +function manifestMapping(text, key) { + if (text === undefined) return "(absent)"; + let manifest; + try { + manifest = JSON.parse(text); + } catch { + return `(not JSON) ${text}`; + } + if (!manifest || typeof manifest !== "object") return `(not an object) ${text}`; + const { exports } = manifest; + const fields = ["name", "type", "main", "module", "types", "typings", "browser", "imports"]; + return JSON.stringify({ + ...Object.fromEntries(fields.map((field) => [field, manifest[field]])), + exports: + exports === undefined || exports === null + ? exports + : { + map: + typeof exports === "object" && + !Array.isArray(exports) && + Object.keys(exports).some((each) => each.startsWith(".")), + entries: exportEntries(exports, key).map(([pattern, value]) => [pattern, value]), + }, + }); +} + +/** The package name an override, resolution, catalog or patch key is about (`a>b@^1` -> `b`). */ +function keyNames(key, names) { + const last = key.split(">").pop().trim(); + const bare = last.replace(/(.)@[^/]*$/, "$1"); + return [...names].filter((name) => bare === name || bare.endsWith(`/${name}`)); +} + +/** + * Top-level YAML map entries as { path, value } (`catalog:` / ` effect: 4.1.2` gives + * { path: ["catalog", "effect"], value: "4.1.2" }); enough for pnpm-workspace.yaml, whose + * catalogs and overrides are plain maps. List items and other lines are skipped. + */ +function yamlEntries(text) { + const entries = []; + const stack = []; + for (const line of text.split("\n")) { + if (/^\s*(#|-|$)/.test(line)) continue; + const match = line.match( + /^(\s*)(?:"([^"]*)"|'([^']*)'|([^\s#'"][^:#]*?))\s*:(?:\s+(.*?))?\s*$/, + ); + if (!match) continue; + const indent = match[1].length; + const key = match[2] ?? match[3] ?? match[4]; + const value = (match[5] ?? "").replace(/\s+#.*$/, "").replace(/^(["'])(.*)\1$/, "$2"); + while (stack.length > 0 && stack.at(-1).indent >= indent) stack.pop(); + entries.push({ path: [...stack.map((each) => each.key), key], value }); + stack.push({ indent, key }); + } + return entries; +} + +/** + * The dependency specs that decide what the imported `names` resolve to: their entries in the + * dependency fields of the `holders` (the package.json files of the packages holding schema code, + * and the root one), the root's `pnpm.overrides`, `pnpm.patchedDependencies` and `resolutions`, + * and pnpm-workspace.yaml's `catalog`, `catalogs`, `overrides` and `patchedDependencies`. Specs of + * other names are not read, so other dependency bumps stay free. `patches` are the patch files of + * those names, guarded like schema code. + * @returns {{ specs: Map; patches: string[] }} + */ +function dependencySpecs(tree, names, holders) { + const specs = new Map(); + const patches = []; + const add = (file, where, name, value) => { + const text = typeof value === "string" ? value : JSON.stringify(value); + specs.set(`${file}\0${where}\0${name}`, { file, where, name, value: text }); + }; + const keyed = (file, where, map, isPatch) => { + if (map === undefined) return; + if (!map || typeof map !== "object") return add(file, where, "*", map); + for (const [key, value] of Object.entries(map)) { + for (const name of keyNames(key, names)) { + add(file, `${where} ${JSON.stringify(key)}`, name, value); + if (isPatch && typeof value === "string") patches.push(path.posix.normalize(value)); + } + } + }; + for (const file of holders) { + if (!tree.has(file)) continue; + let manifest; + try { + manifest = JSON.parse(tree.read(file)); + } catch { + add(file, "(not JSON)", "*", tree.read(file)); + continue; + } + for (const field of DEPENDENCY_FIELDS) { + const deps = manifest?.[field]; + if (deps === undefined) continue; + if (!deps || typeof deps !== "object") { + add(file, field, "*", deps); + continue; + } + for (const name of names) { + if (Object.hasOwn(deps, name)) add(file, field, name, deps[name]); + } + } + if (file === "package.json") { + keyed(file, "pnpm.overrides", manifest?.pnpm?.overrides); + keyed(file, "pnpm.patchedDependencies", manifest?.pnpm?.patchedDependencies, true); + keyed(file, "resolutions", manifest?.resolutions); + } + } + if (tree.has(WORKSPACE_FILE)) { + const sections = ["catalog", "catalogs", "overrides", "patchedDependencies"]; + for (const { path: keys, value } of yamlEntries(tree.read(WORKSPACE_FILE))) { + const [section] = keys; + if (!sections.includes(section)) continue; + // A section written inline (`catalog: { ... }`) is compared whole. + if (keys.length === 1) { + if (value) add(WORKSPACE_FILE, section, "*", value); + continue; + } + if (section === "catalogs" && keys.length === 2) { + if (value) add(WORKSPACE_FILE, `catalogs.${keys[1]}`, "*", value); + continue; + } + const key = section === "catalogs" ? keys[2] : keys[1]; + if (keys.length > (section === "catalogs" ? 3 : 2)) continue; + const where = section === "catalogs" ? `catalogs.${keys[1]}` : section; + for (const name of keyNames(key, names)) { + add(WORKSPACE_FILE, `${where} ${JSON.stringify(key)}`, name, value); + if (section === "patchedDependencies") patches.push(path.posix.normalize(value)); + } + } + } + return { specs, patches }; +} + +/** + * Follows `export * from` and `export { name } from` until a file declares `name`. Adds declaring + * files to `found` and the re-exporting files on the way to `barrels`. + * @returns {boolean} whether `name` was traced to a declaring file from `file` + */ +function declaringFiles(ctx, file, name, found, barrels, seen) { + const key = `${file}\0${name}`; + if (seen.has(key)) return seen.get(key); + seen.set(key, false); + const source = lexed(ctx.tree.read(file)); + const text = source.code; + if (declaresName(text, name)) { + found.add(file); + seen.set(key, true); + return true; + } + let traced = false; + for (const match of text.matchAll(REEXPORT)) { + const [, clause, namespace] = match; + const specifier = source.group(match, 3); + const targets = resolveImport(ctx, file, specifier, new Set(["*"])).files; + if (clause.startsWith("*") && !namespace) { + for (const target of targets) { + if (declaringFiles(ctx, target, name, found, barrels, seen)) traced = true; + } + continue; + } + const exported = namespace + ? namespace === name && "*" + : clause + .slice(1, -1) + .split(",") + .map((part) => + part + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/), + ) + .find((pair) => (pair[1] ?? pair[0]).trim() === name)?.[0] + .trim(); + if (!exported) continue; + for (const target of targets) { + if (exported === "*") found.add(target); + if (exported === "*" || declaringFiles(ctx, target, exported, found, barrels, seen)) { + traced = true; + } + } + } + if (traced) barrels.add(file); + seen.set(key, traced); + return traced; +} + +/** + * Whether `text` declares and exports `name` itself: a declaration (`export async function`, + * `export const { name } = ...` and the like), a local `export { [type] x as name }`, or for + * `default` an `export default`. + */ +function declaresName(text, name) { + const word = name.replace(/\$/g, "\\$"); + const bound = String.raw`(? + match[1].split(",").some( + (part) => + part + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/) + .pop() + .trim() === name, + ), + ); +} + +/** + * Whether an `export const { ... } = ` or `export const [ ... ] = ` pattern binds `word`. A binding + * stands after `{`, `[`, `,`, `:` or `...` and is not a key: in `{ S: renamed }` only `renamed` is + * bound. + */ +function destructuresName(text, word) { + const binding = new RegExp(String.raw`(?:^|[{[,:]|\.\.\.)\s*${word}(?![\w$])(?!\s*:)`); + for (const match of text.matchAll(/\bexport\s+(?:const|let|var)\s*(?=[{[])/g)) { + let depth = 0; + let end = match.index + match[0].length; + for (; end < text.length; end += 1) { + if (text[end] === "{" || text[end] === "[") depth += 1; + else if ((text[end] === "}" || text[end] === "]") && --depth === 0) break; + } + if (binding.test(text.slice(match.index + match[0].length + 1, end))) return true; + } + return false; +} + +function resolveFile(tree, file) { + const candidates = SOURCE_EXTENSIONS.map((extension) => file + extension); + if (file.endsWith(".js")) candidates.unshift(file.replace(/\.js$/, ".ts")); + return candidates.find((candidate) => tree.has(candidate)); +} + +/** + * @param {ReadonlyArray<{ path: string; before?: string; after?: string; sameAsBase?: boolean; fromOldMain?: boolean }>} changes + * one entry per path the branch changes since the merge-base; `before`/`after` are the file + * texts (undefined when absent); `sameAsBase` says the branch's copy equals the base's copy; + * `fromOldMain` says the branch's copy is the one a commit already on main left there + * @param {{ closure: ReadonlySet; baseHasCheck: boolean; staleCommits?: number }} context + * `staleCommits`: the branch's commits that the base already holds as rebased copies + * @returns {string[]} one reason per refused file + */ +function findSchemaChanges(changes, { closure, baseHasCheck, staleCommits = 0 }) { + const reasons = []; + if (staleCommits > 0) { + reasons.push( + `${staleCommits} commit(s) of this branch are already on main (it is based on an old main, ` + + "or it copies main's commits), so rebase it onto main.", + ); + } + const also = staleCommits > 0 ? " (also rebase onto main)" : ""; + for (const change of changes) { + const file = change.path; + const guarded = CHECK_FILES.includes(file) || GITATTRIBUTES.test(file); + // The branch carries main's copy (e.g. it branched before main was rebased): nothing to judge. + if (guarded && change.sameAsBase) continue; + // Main's own older copy, carried by commits already on main: only the rebase is needed. + if (guarded && staleCommits > 0 && change.fromOldMain) { + reasons.push( + `${file}: differs from main's copy, because this branch needs a rebase onto main.`, + ); + continue; + } + if (CHECK_FILES.includes(file)) { + if (baseHasCheck) { + reasons.push( + `${file}: changes the schema check itself. Only the owner may do that: the check stays ` + + `red, and the owner merges past it${also}.`, + ); + } + continue; + } + if (GITATTRIBUTES.test(file)) { + reasons.push( + `${file}: changes how git treats files, which can hide changes from review. Only the ` + + `owner may do that${also}.`, + ); + continue; + } + if (SCHEMA_ROOTS.some((pattern) => pattern.test(file)) || closure.has(file)) { + reasons.push(`${file}: changes the database schema, its migrations or code they import`); + continue; + } + if (!isShippedSource(file)) continue; + const statement = [change.after, change.before].map(schemaStatement).find(Boolean); + if (statement) { + reasons.push( + `${file}: holds a schema statement (${statement.replace(/[^\w${}]+/g, " ")}), so any change ` + + "to it may change the schema", + ); + } + } + return reasons; +} + +/** + * Parses `git diff --raw -z --no-abbrev` output into { path, oldBlob, newBlob } entries. A side + * that is absent or a submodule (no blob) is undefined; the path is still listed. + */ +function parseRawDiff(raw) { + const fields = raw.split("\0"); + const entries = []; + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 2 !== 0) throw new Error("cannot parse git diff --raw output (odd fields)"); + for (let index = 0; index < fields.length; index += 2) { + const header = fields[index]; + if (!/^:\d+ \d+ [0-9a-f]+ [0-9a-f]+ [A-Z]\d*$/.test(header)) { + throw new Error(`cannot parse git diff --raw record: ${JSON.stringify(header)}`); + } + const [oldMode, newMode, oldSha, newSha, status] = header.slice(1).split(" "); + if (/^[RC]/.test(status)) throw new Error(`unexpected rename in diff: ${header}`); + const blob = (mode, sha) => (/^10|^12/.test(mode) && !/^0+$/.test(sha) ? sha : undefined); + entries.push({ + path: fields[index + 1], + oldBlob: blob(oldMode, oldSha), + newBlob: blob(newMode, newSha), + }); + } + return entries; +} + +function git(args) { + return execFileSync("git", ["-c", "core.quotePath=false", ...args], { + encoding: "utf8", + maxBuffer: 512 * 1024 * 1024, + }); +} + +/** The files of a commit (any import may name any file); texts are read only when the walk asks. */ +function readTree(rev) { + const blobs = new Map(); + for (const record of git(["ls-tree", "-r", "-z", "--full-tree", rev]).split("\0")) { + const match = record.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/s); + if (match) blobs.set(match[2], match[1]); + } + return { + files: [...blobs.keys()], + has: (file) => blobs.has(file), + read: (file) => (blobs.has(file) ? readBlob(blobs.get(file)) : ""), + }; +} + +const blobCache = new Map(); +function readBlob(sha) { + if (!blobCache.has(sha)) blobCache.set(sha, git(["cat-file", "blob", sha])); + return blobCache.get(sha); +} + +function treeHas(rev, file) { + return blobAt(rev, file) !== undefined; +} + +/** The blob id of `file` in `rev`, or undefined when absent. */ +function blobAt(rev, file) { + const record = git(["ls-tree", "-z", "--full-tree", rev, "--", file]).split("\0")[0]; + const match = record.match(/^\d+ \w+ ([0-9a-f]+)\t(.+)$/s); + return match && match[2] === file ? match[1] : undefined; +} + +/** + * The branch's commits (merge-base..head) that the base already holds as copies (rebased or + * cherry-picked), matched by author, author time and subject: both keep all three. Needs no file + * contents. Returns the branch-side commit ids. + */ +function staleCommits(mergeBase, base, head) { + const ids = (range) => + git(["log", "--no-merges", "--format=%H%x00%an%x00%ae%x00%at%x00%s", range]) + .split("\n") + .filter(Boolean) + .map((line) => ({ + sha: line.slice(0, line.indexOf("\0")), + id: line.slice(line.indexOf("\0")), + })); + const onBase = new Set(ids(`${mergeBase}..${base}`).map((commit) => commit.id)); + return ids(`${mergeBase}..${head}`) + .filter((commit) => onBase.has(commit.id)) + .map((commit) => commit.sha); +} + +/** + * Judges what the branch changes in how schema code is found, between the merge-base tree and the + * head tree: the guarded closure (both trees), imports the head cannot follow that the merge-base + * could (`known`: the ones already on the merge-base, which the branch did not bring), and changes + * to the workspace list, to the package.json fields that map schema imports, and to the dependency + * specs of the names schema code imports. + * @returns {{ closure: Set; reasons: string[]; known: string[]; outside: string[] }} + */ +function compareTrees(before, after) { + const trees = [before, after]; + const traces = trees.map(traceSchema); + const union = (pick) => new Set(traces.flatMap((trace) => [...pick(trace)])); + const closure = union((trace) => trace.closure); + const reasons = []; + // An import the merge-base already could not follow is not the branch's doing: listed, not + // refused. Only the ones the branch brings (a changed or newly guarded file, or a target it + // moved or broke) are refused. + const onBase = new Set(traces[0].unresolved); + const known = traces[1].unresolved.filter((reason) => onBase.has(reason)); + reasons.push(...traces[1].unresolved.filter((reason) => !onBase.has(reason))); + // Which directories are workspace packages decides what a package import resolves to. + const [listBefore, listAfter] = trees.map(workspacePatterns); + if (JSON.stringify(listBefore) !== JSON.stringify(listAfter)) { + reasons.push( + `${listAfter.source ?? listBefore.source}: changes which directories are workspace ` + + "packages, which can re-point a package the schema code imports", + ); + } + // The package.json fields that map a schema import to files. + const mappings = new Map(); + for (const trace of traces) { + for (const [file, keys] of trace.mappings) { + mappings.set(file, new Set([...(mappings.get(file) ?? []), ...keys])); + } + } + for (const [file, keys] of [...mappings].sort()) { + const read = (tree) => (tree.has(file) ? tree.read(file) : undefined); + const changed = [...keys] + .sort() + .filter((key) => manifestMapping(read(before), key) !== manifestMapping(read(after), key)); + if (changed.length > 0) { + reasons.push( + `${file}: changes how it maps ${changed.map((key) => `"${key}"`).join(", ")}, which the ` + + "schema code imports (the fields for other entries stay free)", + ); + } + } + // The dependency specs of the names schema code imports. + const names = union((trace) => trace.imported); + const holders = union((trace) => trace.holders); + const [specsBefore, specsAfter] = trees.map((tree) => dependencySpecs(tree, names, holders)); + for (const key of new Set([...specsBefore.specs.keys(), ...specsAfter.specs.keys()])) { + const [old, now] = [specsBefore.specs.get(key), specsAfter.specs.get(key)]; + if (old?.value === now?.value) continue; + const { file, where, name } = old ?? now; + const show = (spec) => (spec ? JSON.stringify(spec.value) : "(none)"); + reasons.push( + `${file}: changes what "${name}" resolves to (${where}: ${show(old)} -> ${show(now)}), ` + + "which the schema code imports", + ); + } + for (const patch of [...specsBefore.patches, ...specsAfter.patches]) closure.add(patch); + return { closure, reasons, known, outside: [...union((trace) => trace.outside)].sort() }; +} + +function main(argv) { + const [base, head] = argv; + if (!base || !head) { + console.error("Usage: fork-schema-check.cjs "); + return 2; + } + const mergeBase = git(["merge-base", base, head]).trim(); + const entries = parseRawDiff( + git(["diff", "--raw", "-z", "--no-abbrev", "--no-renames", "--no-ext-diff", mergeBase, head]), + ); + // Only shipped source is searched for statements, so only its texts are read. + const text = (entry, sha) => (sha && isShippedSource(entry.path) ? readBlob(sha) : undefined); + const guarded = (file) => CHECK_FILES.includes(file) || GITATTRIBUTES.test(file); + const stale = staleCommits(mergeBase, base, head); + // A guarded file's copy that one of those commits left: main's older copy, not a branch edit. + const fromOldMain = (entry) => stale.some((sha) => blobAt(sha, entry.path) === entry.newBlob); + const changes = entries.map((entry) => ({ + path: entry.path, + before: text(entry, entry.oldBlob), + after: text(entry, entry.newBlob), + sameAsBase: guarded(entry.path) ? blobAt(base, entry.path) === entry.newBlob : undefined, + fromOldMain: guarded(entry.path) && stale.length > 0 ? fromOldMain(entry) : undefined, + })); + const judged = compareTrees(readTree(mergeBase), readTree(head)); + const { closure, outside } = judged; + const reasons = [...judged.reasons]; + reasons.push( + ...findSchemaChanges(changes, { + closure, + baseHasCheck: treeHas(base, CHECK_SCRIPT), + staleCommits: stale.length, + }), + ); + console.log( + `Schema code: ${closure.size} guarded files. Imports from outside this repository (their ` + + `dependency specs are guarded, their code is not): ${outside.join(", ") || "none"}.`, + ); + if (judged.known.length > 0) { + console.log( + "Imports of the schema code the check cannot follow, already on the merge-base (not this " + + "branch's doing; the check needs teaching, the branch needs no change):", + ); + for (const reason of judged.known) console.log(`- ${reason}`); + } + if (reasons.length === 0) { + console.log(`No database schema changes (${changes.length} changed files checked).`); + return 0; + } + console.error("Fork changes must not change the database schema (no down-migrations exist):"); + for (const reason of reasons) console.error(`- ${reason}`); + return 1; +} + +module.exports = { + compareTrees, + findSchemaChanges, + lex, + parseRawDiff, + schemaClosure, + traceSchema, + CHECK_FILES, + schemaStatement, +}; + +if (require.main === module) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs new file mode 100644 index 0000000000000..cd66805db7a7a --- /dev/null +++ b/.github/scripts/fork-schema-check.test.cjs @@ -0,0 +1,1103 @@ +const assert = require("node:assert/strict"); +const { execFileSync, spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { + CHECK_FILES, + compareTrees, + findSchemaChanges, + parseRawDiff, + schemaClosure, + traceSchema, +} = require("./fork-schema-check.cjs"); + +const SCRIPT = path.join(__dirname, "fork-schema-check.cjs"); +const WORKFLOW = path.join(__dirname, "..", "workflows", "fork-checks.yml"); +const MIGRATIONS = "apps/server/src/persistence/Migrations"; +const SEARCH_INDEX = "apps/server/src/orchestration-v2/messageSearchIndex.ts"; + +const TRIGGER_FILE = [ + 'import * as SqlClient from "effect/sql/SqlClient";', + "export const createMessageSearchTriggers = (sql) => sql`", + " CREATE TRIGGER message_search_update", + " AFTER UPDATE ON messages", + " WHEN (old.streaming = 0 OR new.streaming = 0)", + " BEGIN SELECT 1; END", + "`;", + "", +].join("\n"); + +const treeOf = (files) => ({ + files: Object.keys(files), + has: (file) => Object.hasOwn(files, file), + read: (file) => files[file] ?? "", +}); +const changed = (file, before, after) => ({ path: file, before, after }); +const noClosure = { closure: new Set(), baseHasCheck: true }; + +test("refuses any change to a migration or the schema setup", () => { + const reasons = findSchemaChanges( + [ + changed(`${MIGRATIONS}/082_New.ts`, undefined, "export default 1;"), + changed("apps/server/src/persistence/Migrations.ts", "a", "b"), + changed("apps/server/src/persistence/databaseMigrationHead.ts", "a", "b"), + changed(`${MIGRATIONS}/001_A.ts`, "export default 1;", undefined), + ], + noClosure, + ); + assert.equal(reasons.length, 4); +}); + +test("refuses a schema statement split over lines, before or after the change", () => { + const added = "export const x = sql`\n CREATE\n TABLE IF NOT EXISTS fork_state (id TEXT)`;"; + const reasons = findSchemaChanges( + [ + changed("apps/server/src/feature/Store.ts", "export {};", added), + changed(SEARCH_INDEX, TRIGGER_FILE, TRIGGER_FILE.replace(" OR new.streaming = 0", "")), + ], + noClosure, + ); + assert.equal(reasons.length, 2); + assert.match(reasons[0], /Store\.ts: holds a schema statement \(CREATE TABLE\)/); + assert.match(reasons[1], /messageSearchIndex\.ts: holds a schema statement \(CREATE TRIGGER\)/); +}); + +test("refuses statements built with interpolation, string joins or SQL comments", () => { + const shapes = [ + "export const make = (sql, kind) => sql`CREATE ${kind} fork_state (id TEXT)`;", + 'export const ddl = "CREATE " +\n "TABLE fork_state (id TEXT)";', + "export const ddl = sql`CREATE /* fork */ TABLE fork_state (id TEXT)`;", + "export const ddl = sql`ALTER -- fork\n TABLE threads ADD COLUMN x`;", + ]; + const reasons = findSchemaChanges( + shapes.map((text, index) => changed(`apps/server/src/fork/state${index}.ts`, "", text)), + noClosure, + ); + assert.equal(reasons.length, shapes.length, reasons.join("\n")); + for (const reason of reasons) assert.match(reason, /holds a schema statement/); + // UI text with the same words in title case stays free. + assert.deepEqual( + findSchemaChanges( + [changed("apps/web/src/Git.tsx", "", "const label = `Create ${terms.shortLabel}`;")], + noClosure, + ), + [], + ); +}); + +test("a check file or .gitattributes the branch carries unchanged from the base passes", () => { + const changes = [...CHECK_FILES, ".gitattributes"].map((file) => ({ + ...changed(file, undefined, "same as main"), + sameAsBase: true, + })); + assert.deepEqual(findSchemaChanges(changes, noClosure), []); +}); + +test("a branch built on an old, rebased main is told to rebase, not to merge past red", () => { + const changes = [{ ...changed(CHECK_FILES[0], undefined, "old copy"), fromOldMain: true }]; + const reasons = findSchemaChanges(changes, { ...noClosure, staleCommits: 2 }); + assert.equal(reasons.length, 2); + for (const reason of reasons) { + assert.match(reason, /rebase/); + assert.doesNotMatch(reason, /merges past/); + } +}); + +test("on a stale branch, a real edit to the check is still reported as one", () => { + const changes = [{ ...changed(CHECK_FILES[0], "a", "weakened"), fromOldMain: false }]; + const reasons = findSchemaChanges(changes, { ...noClosure, staleCommits: 1 }); + assert.equal(reasons.length, 2); + assert.match(reasons[0], /already on main/); + assert.match(reasons[1], /changes the schema check itself.*\(also rebase onto main\)/); +}); + +test("parseRawDiff fails loudly on output it cannot read", () => { + const sha = "a".repeat(40); + const zero = "0".repeat(40); + assert.deepEqual(parseRawDiff(`:000000 100644 ${zero} ${sha} A\0new.ts\0`), [ + { path: "new.ts", oldBlob: undefined, newBlob: sha }, + ]); + assert.deepEqual(parseRawDiff(""), []); + assert.throws(() => parseRawDiff("warning: something\0x\0"), /cannot parse/); + assert.throws(() => parseRawDiff(`:000000 100644 ${zero} ${sha} A\0`), /cannot parse/); +}); + +test("allows schema statements in tests and ordinary changes elsewhere", () => { + assert.deepEqual( + findSchemaChanges( + [ + changed("apps/server/src/feature/Store.test.ts", "", "CREATE TABLE scratch (id TEXT)"), + changed("apps/server/src/persistence/testkit/Fake.ts", "", "CREATE INDEX i ON t (x)"), + changed("apps/server/src/provider/Child.testkit.ts", "", "CREATE TABLE t (x)"), + changed("apps/server/src/feature/Store.ts", "", "const label = 'create a table view';"), + changed("docs/notes.md", "", "ALTER TABLE in prose"), + changed( + "packages/contracts/src/settings.ts", + "", + " mcpProfile: Schema.optional(Schema.String),", + ), + ], + noClosure, + ), + [], + ); +}); + +test("refuses changes to files the migrations import", () => { + const reasons = findSchemaChanges([changed("packages/shared/src/helper.ts", "a", "b")], { + closure: new Set(["packages/shared/src/helper.ts"]), + baseHasCheck: true, + }); + assert.match(reasons[0], /helper\.ts: changes the database schema, its migrations or code/); +}); + +test("refuses changes to the check itself and to .gitattributes once the base has the check", () => { + const changes = [...CHECK_FILES, ".gitattributes", "apps/.gitattributes"].map((file) => + changed(file, "a", "b"), + ); + const reasons = findSchemaChanges(changes, noClosure); + assert.equal(reasons.length, 5); + for (const reason of reasons.slice(0, 3)) assert.match(reason, /Only the owner may do that/); + // The series that first adds the check (base is an upstream tag) may add its files. + assert.equal(findSchemaChanges(changes, { closure: new Set(), baseHasCheck: false }).length, 2); +}); + +test("the closure follows relative and package subpath imports, not root barrels or test-only files", () => { + const closure = schemaClosure( + treeOf({ + [`${MIGRATIONS}/001_A.ts`]: `import { t } from "../../orchestration-v2/messageSearchIndex.ts";`, + [`${MIGRATIONS}/001_A.test.ts`]: 'import x from "../../feature/OnlyTests.ts";', + // A test-path file that shipped schema code imports is guarded like any other. + "apps/server/src/persistence/Migrations.ts": + 'import m from "./Migrations/001_A.ts";\nimport "./testkit/seed.ts";', + "apps/server/src/persistence/testkit/seed.ts": "", + [SEARCH_INDEX]: [ + 'import { a } from "@t3tools/shared/assistantCitations";', + 'import { Thread } from "@t3tools/contracts";', + 'import type { B } from "./types.js";', + ].join("\n"), + "apps/server/src/orchestration-v2/types.ts": "export type B = 1;", + "apps/server/src/feature/OnlyTests.ts": "", + "packages/shared/package.json": JSON.stringify({ + name: "@t3tools/shared", + exports: { "./assistantCitations": { import: "./src/assistantCitations.ts" } }, + }), + "packages/shared/src/assistantCitations.ts": 'import { c } from "./threadLinks.ts";', + "packages/shared/src/threadLinks.ts": "", + "packages/contracts/package.json": JSON.stringify({ + name: "@t3tools/contracts", + exports: { ".": "./src/index.ts" }, + }), + "packages/contracts/src/index.ts": "", + }), + ); + assert.deepEqual([...closure].sort(), [ + "apps/server/src/orchestration-v2/messageSearchIndex.ts", + "apps/server/src/orchestration-v2/types.ts", + "apps/server/src/persistence/Migrations.ts", + `${MIGRATIONS}/001_A.ts`, + "apps/server/src/persistence/testkit/seed.ts", + // `Thread` is declared nowhere, so the barrel is guarded whole (fail closed). + "packages/contracts/src/index.ts", + "packages/shared/src/assistantCitations.ts", + "packages/shared/src/threadLinks.ts", + ]); +}); + +test("the closure follows a package root only to the files that declare the imported names", () => { + const closure = schemaClosure( + treeOf({ + [`${MIGRATIONS}/064_Links.ts`]: [ + "import {", + " ThreadLinkedPullRequest,", + " type ThreadPullRequestKey,", + '} from "@t3tools/contracts";', + 'import { Renamed } from "@t3tools/contracts";', + ].join("\n"), + "packages/contracts/package.json": JSON.stringify({ + name: "@t3tools/contracts", + exports: { ".": { types: "./src/index.ts", import: "./src/index.ts" } }, + }), + "packages/contracts/src/index.ts": [ + 'export * from "./settings.ts";', + 'export * from "./threadPullRequest.ts";', + 'export { Original as Renamed } from "./aliases.ts";', + ].join("\n"), + "packages/contracts/src/settings.ts": "export const Settings = 1;", + "packages/contracts/src/threadPullRequest.ts": [ + 'import { Url } from "./baseSchemas.ts";', + "export const ThreadLinkedPullRequest = Url;", + "export type ThreadPullRequestKey = string;", + ].join("\n"), + "packages/contracts/src/baseSchemas.ts": "export const Url = 1;", + "packages/contracts/src/aliases.ts": "const Original = 1;\nexport { Original };", + }), + ); + assert.deepEqual([...closure].sort(), [ + `${MIGRATIONS}/064_Links.ts`, + "packages/contracts/src/aliases.ts", + "packages/contracts/src/baseSchemas.ts", + // The barrel on the way is guarded (an edit to it could re-point a name), not followed: + // settings.ts stays free. + "packages/contracts/src/index.ts", + "packages/contracts/src/threadPullRequest.ts", + ]); +}); + +// A migration importing `S` from a package root whose barrel re-exports `./a.ts` with `reexport`. +const rootImportTree = (reexport, declaring, extra = {}) => + treeOf({ + [`${MIGRATIONS}/001_A.ts`]: 'import { S } from "@t3tools/c";', + "packages/c/package.json": JSON.stringify({ name: "@t3tools/c", exports: "./src/index.ts" }), + "packages/c/src/index.ts": `${reexport}\nexport * from "./other.ts";`, + "packages/c/src/a.ts": declaring, + "packages/c/src/other.ts": "export const Other = 1;", + ...extra, + }); + +test("the closure traces the declaration shapes a name can have", () => { + const shapes = [ + ['export * from "./a.ts";', "export async function S() {}"], + ['export * from "./a.ts";', "const S = 1;\ntype T = 2;\nexport { type T, S };"], + ['export * from "./a.ts";', "type X = 1;\nexport { type X as S };"], + ['export { default as S } from "./a.ts";', "export default function () {}"], + ['export * from "./a.ts";', "const o = { S: 1 };\nexport const { S } = o;"], + ['export * from "./a.ts";', "export const [first, S] = [1, 2];"], + ['export * from "./a.ts";', "export function* S() {}"], + ]; + for (const [reexport, declaring] of shapes) { + assert.deepEqual( + [...schemaClosure(rootImportTree(reexport, declaring))].sort(), + [`${MIGRATIONS}/001_A.ts`, "packages/c/src/a.ts", "packages/c/src/index.ts"], + declaring, + ); + } +}); + +test("a name the closure cannot trace guards the whole barrel and what it re-exports", () => { + const closure = schemaClosure( + rootImportTree('export * from "./a.ts";', "export const NotS = 1;"), + ); + assert.deepEqual([...closure].sort(), [ + `${MIGRATIONS}/001_A.ts`, + "packages/c/src/a.ts", + "packages/c/src/index.ts", + "packages/c/src/other.ts", + ]); +}); + +test("a destructuring key is not a binding: `{ S: renamed }` declares renamed, not S", () => { + const traced = (declaring) => + !schemaClosure(rootImportTree('export * from "./a.ts";', declaring)).has( + "packages/c/src/other.ts", + ); + assert.equal(traced("const o = { S: 1 };\nexport const { S: renamed } = o;"), false); + assert.equal(traced("export const { a = { S: 1 } } = o;"), false); + for (const shape of [ + "export const { a: S } = o;", + "export const { a: { S } } = o;", + "export const { S = 1 } = o;", + "export const { a, ...S } = o;", + "export const [a, [S]] = list;", + ]) { + assert.equal(traced(shape), true, shape); + } +}); + +// A migration importing `S` from the workspace package `@t3tools/c`, described by `manifest`. +const packageTree = (manifest, extra = {}) => + treeOf({ + [`${MIGRATIONS}/001_A.ts`]: + 'import { S } from "@t3tools/c";\nimport { U } from "@t3tools/c/util";', + "packages/c/package.json": JSON.stringify({ name: "@t3tools/c", ...manifest }), + "packages/c/src/index.ts": "export const S = 1;", + "packages/c/src/node.ts": "export const S = 2;", + "packages/c/src/util.ts": "export const U = 1;", + ...extra, + }); + +test("a package import guards every file any export condition can pick, and its mapping", () => { + const trace = traceSchema( + packageTree({ + exports: { + ".": { node: "./src/node.ts", import: ["./src/index.ts", "./dist/index.js"] }, + "./*": { worker: { default: "./src/*.ts" } }, + }, + }), + ); + assert.deepEqual(trace.unresolved, []); + assert.deepEqual([...trace.closure].sort(), [ + `${MIGRATIONS}/001_A.ts`, + "packages/c/src/index.ts", + "packages/c/src/node.ts", + "packages/c/src/util.ts", + ]); + // The package.json is judged by the entries that map these imports, not as a whole file. + assert.deepEqual( + [...trace.mappings].map(([file, keys]) => [file, [...keys].sort()]), + [["packages/c/package.json", [".", "./util"]]], + ); + assert.deepEqual([...trace.imported], ["@t3tools/c"]); +}); + +test("a package without exports resolves through main, module, types and index", () => { + const closure = schemaClosure( + packageTree({ main: "./src/node.ts", module: "./src/index.ts" }, { "packages/c/util.ts": "" }), + ); + assert.deepEqual([...closure].sort(), [ + `${MIGRATIONS}/001_A.ts`, + "packages/c/src/index.ts", + "packages/c/src/node.ts", + // Without exports, a subpath is a path inside the package. + "packages/c/util.ts", + ]); + // A string `browser` field is a fallback entry as well. + assert.ok(schemaClosure(packageTree({ browser: "./src/node.ts" })).has("packages/c/src/node.ts")); +}); + +test("an in-repository import that resolves to no file is reported, not skipped", () => { + const cases = [ + [ + { exports: { ".": { import: "./src/missing.ts" } } }, + /"@t3tools\/c", but @t3tools\/c is a package of this repository/, + ], + [{ exports: { "./other": "./src/util.ts" } }, /"@t3tools\/c", but @t3tools\/c is a package/], + [{}, /"@t3tools\/c\/util", but @t3tools\/c is a package/], + ]; + for (const [manifest, pattern] of cases) { + const trace = traceSchema(packageTree(manifest)); + assert.ok( + trace.unresolved.some((reason) => pattern.test(reason)), + trace.unresolved.join("\n"), + ); + for (const reason of trace.unresolved) + assert.match(reason, /^apps\/server\/.*001_A\.ts: imports/); + } + const relative = traceSchema( + treeOf({ [`${MIGRATIONS}/001_A.ts`]: 'import x from "./gone.ts";' }), + ); + assert.match( + relative.unresolved.join(""), + /imports "\.\/gone\.ts", but no file in the repository/, + ); + const shapes = traceSchema( + treeOf({ + [`${MIGRATIONS}/001_A.ts`]: [ + 'import { a } from "#internal/a";', + "const m = await import(`./${name}.ts`);", + "const n = require(name);", + 'const all = import.meta.glob("./*.ts");', + ].join("\n"), + }), + ).unresolved.join("\n"); + assert.match(shapes, /imports "#internal\/a", but it is neither a relative import nor a package/); + assert.match(shapes, /imports "import\(\.\.\.", but its target is computed/); + assert.match(shapes, /imports "require\(\.\.\.", but its target is computed/); + assert.match(shapes, /imports "import\.meta\.glob\.\.\.", but its target is computed/); + // A manifest that is not JSON hides its name, so no import is trusted to be from outside. + const broken = traceSchema( + treeOf({ + [`${MIGRATIONS}/001_A.ts`]: 'import { S } from "@t3tools/d";', + "packages/d/package.json": "{ not json", + }), + ); + assert.match(broken.unresolved.join(""), /packages\/d\/package\.json is not JSON/); +}); + +test("workspace packages come from the workspace list, scoped or not, at any depth", () => { + const trace = traceSchema( + treeOf({ + "pnpm-workspace.yaml": + 'packages:\n - "packages/*"\n - apps/**\n - "!apps/skip"\n\ncatalog:\n x: 1\n', + [`${MIGRATIONS}/001_A.ts`]: [ + 'import { a } from "plain-pkg/schema";', + 'import { b } from "@t3tools/deep";', + 'import { c } from "@t3tools/skipped";', + 'import { d } from "effect/Schema";', + 'import * as fs from "node:fs";', + ].join("\n"), + "packages/plain/package.json": JSON.stringify({ + name: "plain-pkg", + exports: { "./schema": { import: "./src/schema.ts" } }, + }), + "packages/plain/src/schema.ts": "export const a = 1;", + "apps/mobile/modules/deep/package.json": JSON.stringify({ + name: "@t3tools/deep", + main: "index.ts", + }), + "apps/mobile/modules/deep/index.ts": "export const b = 1;", + // Not a workspace member, so not what the import resolves to. + "apps/skip/package.json": JSON.stringify({ name: "@t3tools/skipped", main: "index.ts" }), + "apps/skip/index.ts": "", + "vendor/effect/package.json": JSON.stringify({ name: "effect", main: "index.ts" }), + }), + ); + assert.deepEqual(trace.unresolved, []); + assert.deepEqual([...trace.closure].sort(), [ + "apps/mobile/modules/deep/index.ts", + `${MIGRATIONS}/001_A.ts`, + "packages/plain/src/schema.ts", + ]); + assert.deepEqual([...trace.outside].sort(), ["@t3tools/skipped", "effect", "node:fs"]); +}); + +test("the workflow pins actions by commit SHA", () => { + const uses = fs.readFileSync(WORKFLOW, "utf8").match(/^\s*(?:-\s*)?uses:\s*\S+/gm) ?? []; + assert.ok(uses.length > 0); + for (const line of uses) assert.match(line, /@[0-9a-f]{40}$/, line); +}); + +test("the workflow runs the default branch's copy and never runs pull request code", () => { + const workflow = fs.readFileSync(WORKFLOW, "utf8"); + const code = workflow.replace(/^\s*#.*$/gm, ""); + assert.match(code, /^on:\n {2}pull_request_target:/m); + assert.doesNotMatch(code, /^\s+pull_request:/m); + // No checkout of the pull request: actions/checkout without `ref` takes the default branch. + assert.doesNotMatch(code, /\bref:/); + assert.doesNotMatch(code, /git (checkout|switch|worktree|reset)/); + assert.match(code, /persist-credentials: false/); + // Event values reach the shell only through env, never pasted into the script. + for (const line of code.split("\n").filter((l) => /run:/.test(l))) { + assert.doesNotMatch(line, /\$\{\{/, line); + } + assert.match(code, /node \.github\/scripts\/fork-schema-check\.cjs "\$BASE_SHA" "\$HEAD_SHA"/); +}); + +// End to end: the real git in a scratch repository, the way CI calls the check. + +function scratchRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-schema-check-")); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@example.invalid", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@example.invalid", + }; + const git = (...args) => execFileSync("git", args, { cwd: dir, env, encoding: "utf8" }).trim(); + const write = (files) => { + for (const [file, text] of Object.entries(files)) { + const target = path.join(dir, file); + if (text === null) { + fs.rmSync(target); + continue; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, text); + } + }; + const commit = (files, message = "change") => { + write(files); + git("add", "-A"); + git("commit", "-q", "--allow-empty", "-m", message); + return git("rev-parse", "HEAD"); + }; + git("init", "-q", "-b", "main"); + const check = (base, head) => { + const result = spawnSync(process.execPath, [SCRIPT, base, head], { + cwd: dir, + env, + encoding: "utf8", + }); + return { code: result.status, output: result.stdout + result.stderr }; + }; + return { dir, git, commit, check }; +} + +const BASE_TREE = { + [CHECK_FILES[0]]: "// the check\n", + [CHECK_FILES[1]]: "// its tests\n", + [CHECK_FILES[2]]: "name: Fork checks\n", + "apps/server/src/persistence/Migrations.ts": 'import m from "./Migrations/081_Search.ts";\n', + [`${MIGRATIONS}/081_Search.ts`]: + 'import { createMessageSearchTriggers } from "../../orchestration-v2/messageSearchIndex.ts";\n', + [SEARCH_INDEX]: TRIGGER_FILE, + "apps/server/src/feature/Store.ts": "export const x = 1;\n", +}; + +function prAgainstBase(files, baseFiles = BASE_TREE) { + const repo = scratchRepo(); + const base = repo.commit(baseFiles); + const head = repo.commit(files); + try { + return repo.check(base, head); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +} + +test("e2e: an ordinary change passes and says how many files it checked", () => { + const result = prAgainstBase({ "apps/server/src/feature/Store.ts": "export const x = 2;\n" }); + assert.equal(result.code, 0, result.output); + assert.match(result.output, /No database schema changes \(1 changed files checked\)/); +}); + +test("e2e: a pull request that edits the check is refused, however small the edit", () => { + const result = prAgainstBase({ [CHECK_FILES[0]]: "// the check, weakened\n" }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /fork-schema-check\.cjs: changes the schema check itself/); +}); + +test("e2e: a pull request that deletes the workflow is refused", () => { + const result = prAgainstBase({ [CHECK_FILES[2]]: null }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /fork-checks\.yml: changes the schema check itself/); +}); + +test("e2e: the series that first adds the check passes (base has no check)", () => { + const { + [CHECK_FILES[0]]: _a, + [CHECK_FILES[1]]: _b, + [CHECK_FILES[2]]: _c, + ...upstream + } = BASE_TREE; + const result = prAgainstBase( + { [CHECK_FILES[0]]: "// the check\n", [CHECK_FILES[2]]: "name: Fork checks\n" }, + upstream, + ); + assert.equal(result.code, 0, result.output); +}); + +test("e2e: an edit inside a trigger body outside the migrations folder is refused", () => { + const result = prAgainstBase({ + [SEARCH_INDEX]: TRIGGER_FILE.replace(" OR new.streaming = 0", ""), + }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /messageSearchIndex\.ts: changes the database schema/); +}); + +test("e2e: a file the migrations import is refused even without a schema statement", () => { + const files = { + ...BASE_TREE, + [SEARCH_INDEX]: 'export { keyFor } from "./searchKey.ts";\n', + "apps/server/src/orchestration-v2/searchKey.ts": "export const keyFor = (x) => x;\n", + }; + const repo = scratchRepo(); + const base = repo.commit(files); + const head = repo.commit({ + "apps/server/src/orchestration-v2/searchKey.ts": "export const keyFor = (x) => `${x}!`;\n", + }); + const result = repo.check(base, head); + fs.rmSync(repo.dir, { recursive: true, force: true }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /searchKey\.ts: changes the database schema/); +}); + +test("e2e: a new store with CREATE TABLE split over lines is refused", () => { + const result = prAgainstBase({ + "apps/server/src/feature/ForkState.ts": + "export const init = (sql) => sql`\n CREATE\n TABLE IF NOT EXISTS fork_state (id TEXT)\n`;\n", + }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /ForkState\.ts: holds a schema statement \(CREATE TABLE\)/); +}); + +test("e2e: a migration with a non-ASCII name is refused", () => { + const result = prAgainstBase({ [`${MIGRATIONS}/082_Forké.ts`]: "export default 1;\n" }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /Migrations\/082_Forké\.ts: changes the database schema/); +}); + +test("e2e: .gitattributes cannot hide a migration change", () => { + const result = prAgainstBase({ + ".gitattributes": "apps/server/src/persistence/** -diff\n", + [`${MIGRATIONS}/081_Search.ts`]: `${BASE_TREE[`${MIGRATIONS}/081_Search.ts`]}// more\n`, + }); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /\.gitattributes: changes how git treats files/); + assert.match(result.output, /081_Search\.ts: changes the database schema/); +}); + +test("e2e: renaming or deleting a migration is refused", () => { + const renamed = prAgainstBase({ + [`${MIGRATIONS}/081_Search.ts`]: null, + [`${MIGRATIONS}/081_Renamed.ts`]: BASE_TREE[`${MIGRATIONS}/081_Search.ts`], + }); + assert.equal(renamed.code, 1, renamed.output); + assert.match(renamed.output, /081_Search\.ts: changes/); + assert.match(renamed.output, /081_Renamed\.ts: changes/); +}); + +test("e2e: a barrel-only edit that re-points a name a migration imports is refused", () => { + const files = { + ...BASE_TREE, + [`${MIGRATIONS}/064_Links.ts`]: + 'import { ThreadLinkedPullRequest } from "@t3tools/contracts";\n', + "packages/contracts/package.json": JSON.stringify({ + name: "@t3tools/contracts", + exports: { ".": "./src/index.ts" }, + }), + "packages/contracts/src/index.ts": + 'export * from "./orchestration.ts";\nexport * from "./threadPullRequest.ts";\n', + "packages/contracts/src/orchestration.ts": "export const ProviderKind = 1;\n", + "packages/contracts/src/threadPullRequest.ts": "export const ThreadLinkedPullRequest = 2;\n", + }; + const repo = scratchRepo(); + try { + const base = repo.commit(files); + const head = repo.commit({ + "packages/contracts/src/index.ts": `${files["packages/contracts/src/index.ts"]}export { ProviderKind as ThreadLinkedPullRequest } from "./orchestration.ts";\n`, + }); + const result = repo.check(base, head); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /contracts\/src\/index\.ts: changes the database schema/); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test("e2e: an edit to a declaring file the closure cannot trace is refused (fail closed)", () => { + const files = { + ...BASE_TREE, + [`${MIGRATIONS}/001_A.ts`]: 'import { S } from "@t3tools/c";\n', + "packages/c/package.json": JSON.stringify({ name: "@t3tools/c", exports: "./src/index.ts" }), + "packages/c/src/index.ts": 'export * from "./a.ts";\n', + // A shape the tracer does not know: `S` is declared, but not in a way it recognises. + "packages/c/src/a.ts": + "const o = { S: 1 };\nexport default o;\nexport const { S: renamed } = o;\nexports.S = 1;\n", + }; + const repo = scratchRepo(); + try { + const base = repo.commit(files); + const head = repo.commit({ + "packages/c/src/a.ts": `${files["packages/c/src/a.ts"]}// edited\n`, + }); + const result = repo.check(base, head); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /packages\/c\/src\/a\.ts: changes the database schema/); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test("e2e: after main is rebased onto a new upstream release, an old branch is told to rebase", () => { + const { + [CHECK_FILES[0]]: script, + [CHECK_FILES[1]]: tests, + [CHECK_FILES[2]]: workflow, + ...upstreamFiles + } = BASE_TREE; + const repo = scratchRepo(); + try { + const upstreamOld = repo.commit(upstreamFiles, "upstream v1"); + const checkPatch = repo.commit( + { [CHECK_FILES[0]]: script, [CHECK_FILES[1]]: tests, [CHECK_FILES[2]]: workflow }, + "fork: schema check", + ); + repo.git("branch", "old-main", checkPatch); + // A pull request opened from the old main. + repo.git("checkout", "-q", "-b", "pr", checkPatch); + const prHead = repo.commit({ "README.md": "fork notes\n" }, "docs: fork notes"); + // The follow workflow: a new upstream release, the patch series replayed on it, main moved. + repo.git("checkout", "-q", "-b", "new-main", upstreamOld); + repo.commit({ "apps/server/src/feature/Other.ts": "export {};\n" }, "upstream v2"); + repo.git("cherry-pick", checkPatch); + const newMain = repo.git("rev-parse", "HEAD"); + + const stale = repo.check(newMain, prHead); + assert.equal(stale.code, 1, stale.output); + assert.match(stale.output, /1 commit\(s\) of this branch are already on main/); + assert.match(stale.output, /rebase it onto main/); + assert.doesNotMatch(stale.output, /rebased copies|has since been rebased/); + assert.doesNotMatch(stale.output, /merges past it|changes the schema check itself/); + + // The same branch rebased onto the new main passes. + repo.git("checkout", "-q", "pr"); + repo.git("rebase", "-q", "--onto", "new-main", "old-main"); + const rebased = repo.check(newMain, repo.git("rev-parse", "HEAD")); + assert.equal(rebased.code, 0, rebased.output); + + // A branch that really edits the check is still refused against the new main. + const edit = repo.commit({ [CHECK_FILES[0]]: "// weakened\n" }, "weaken the check"); + const refused = repo.check(newMain, edit); + assert.equal(refused.code, 1, refused.output); + assert.match(refused.output, /fork-schema-check\.cjs: changes the schema check itself/); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test("e2e: a branch behind a main that moved forward normally is not called stale", () => { + const repo = scratchRepo(); + try { + repo.commit(BASE_TREE, "fork main"); + repo.git("checkout", "-q", "-b", "pr"); + const head = repo.commit({ "README.md": "notes\n" }, "docs: notes"); + repo.git("checkout", "-q", "main"); + const moved = repo.commit({ [CHECK_FILES[0]]: "// the check, v2\n" }, "fork: check v2"); + const result = repo.check(moved, head); + assert.equal(result.code, 0, result.output); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test("e2e: a stale branch that also edits the check is told both", () => { + const { [CHECK_FILES[0]]: script, ...rest } = BASE_TREE; + const repo = scratchRepo(); + try { + const old = repo.commit(rest, "upstream v1"); + const patch = repo.commit({ [CHECK_FILES[0]]: script }, "fork: schema check"); + repo.git("checkout", "-q", "-b", "pr", patch); + const head = repo.commit({ [CHECK_FILES[0]]: "// weakened\n" }, "weaken the check"); + repo.git("checkout", "-q", "-b", "new-main", old); + repo.commit({ "apps/server/src/feature/Other.ts": "export {};\n" }, "upstream v2"); + repo.git("cherry-pick", patch); + const result = repo.check(repo.git("rev-parse", "HEAD"), head); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /already on main/); + assert.match(result.output, /fork-schema-check\.cjs: changes the schema check itself/); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test("e2e: a branch behind main that only cherry-picks a main commit is told to rebase", () => { + const repo = scratchRepo(); + try { + repo.commit(BASE_TREE, "fork main"); + repo.git("checkout", "-q", "-b", "pr"); + repo.commit({ "README.md": "notes\n" }, "docs: notes"); + repo.git("checkout", "-q", "main"); + const fix = repo.commit({ "apps/server/src/feature/Store.ts": "export const x = 3;\n" }, "fix"); + repo.git("checkout", "-q", "pr"); + repo.git("cherry-pick", fix); + const result = repo.check(fix, repo.git("rev-parse", "HEAD")); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /already on main \(it is based on an old main, or it copies/); + assert.doesNotMatch(result.output, /has since been rebased/); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +// The base of the package.json tests: migration 001 imports `S` from `@t3tools/c`, whose barrel +// re-exports it from a.ts; settings.ts, also in the package, exports an `S` of its own. +const PACKAGE_BASE = { + ...BASE_TREE, + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + [`${MIGRATIONS}/001_A.ts`]: 'import { S } from "@t3tools/c";\n', + "packages/c/package.json": JSON.stringify({ + name: "@t3tools/c", + exports: { ".": { types: "./src/index.ts", import: "./src/index.ts" } }, + }), + "packages/c/src/index.ts": 'export * from "./a.ts";\n', + "packages/c/src/a.ts": "export const S = 1;\n", + "packages/c/src/settings.ts": "export const S = 2;\n", +}; +const manifest = (fields) => JSON.stringify({ name: "@t3tools/c", ...fields }); + +test("e2e: a package.json edit that re-points a name a migration imports is refused", () => { + const tricks = { + "the import target swapped to another file exporting the name": { + "packages/c/package.json": manifest({ exports: { ".": { import: "./src/settings.ts" } } }), + }, + "a node condition that Node picks before import": { + "packages/c/package.json": manifest({ + exports: { ".": { node: "./src/evil.ts", import: "./src/index.ts" } }, + }), + "packages/c/src/evil.ts": "export const S = 3;\n", + }, + "exports removed in favour of main": { + "packages/c/package.json": manifest({ main: "./src/evil.ts" }), + "packages/c/src/evil.ts": "export const S = 3;\n", + }, + }; + const passed = Object.entries(tricks).filter(([, files]) => { + const result = prAgainstBase(files, PACKAGE_BASE); + return !( + result.code === 1 && + /packages\/c\/package\.json: changes how it maps "\."/.test(result.output) + ); + }); + assert.deepEqual( + passed.map(([trick]) => trick), + [], + ); + // The rest of the package stays free. + const free = prAgainstBase( + { "packages/c/src/settings.ts": "export const S = 4;\n" }, + PACKAGE_BASE, + ); + assert.equal(free.code, 0, free.output); +}); + +test("e2e: an import of a workspace package the check cannot resolve is refused, naming it", () => { + const broken = { + "packages/c/package.json": manifest({ exports: { ".": { import: "./src/missing.ts" } } }), + }; + const result = prAgainstBase(broken, PACKAGE_BASE); + assert.equal(result.code, 1, result.output); + assert.match( + result.output, + /001_A\.ts: imports "@t3tools\/c", but @t3tools\/c is a package of this repository \(packages\/c\/package\.json\)/, + ); + // A PR that removes the file the entry points to is refused the same way. + const removed = prAgainstBase({ "packages/c/src/index.ts": null }, PACKAGE_BASE); + assert.equal(removed.code, 1, removed.output); + assert.match(removed.output, /imports "@t3tools\/c", but/); +}); + +test("e2e: an import the merge-base already cannot follow is listed, not blamed on the PR", () => { + // As after an upstream release that brings an import shape the check does not know. + const upstream = { + ...PACKAGE_BASE, + "packages/c/package.json": manifest({ exports: { ".": { import: "./src/missing.ts" } } }), + [`${MIGRATIONS}/002_B.ts`]: "export const load = (name) => import(name);\n", + "apps/server/src/persistence/Migrations.ts": `${BASE_TREE["apps/server/src/persistence/Migrations.ts"]}import b from "./Migrations/002_B.ts";\n`, + }; + const result = prAgainstBase( + { "apps/server/src/feature/Store.ts": "export const x = 2;\n" }, + upstream, + ); + assert.equal(result.code, 0, result.output); + assert.match(result.output, /already on the merge-base \(not this branch's doing/); + assert.match(result.output, /002_B\.ts: imports "import\(\.\.\.", but its target is computed/); + assert.match(result.output, /001_A\.ts: imports "@t3tools\/c", but/); +}); + +test("an unfollowable import is refused when the branch brings it, not when the base has it", () => { + const files = (loader) => ({ + [`${MIGRATIONS}/001_A.ts`]: 'import { load } from "../loader.ts";', + "apps/server/src/persistence/loader.ts": loader, + }); + const plain = "export const load = () => 1;"; + const computed = "export const load = (name) => require(name);"; + const brought = compareTrees(treeOf(files(plain)), treeOf(files(computed))); + assert.match(brought.reasons.join("\n"), /loader\.ts: imports "require\(\.\.\.", but/); + assert.deepEqual(brought.known, []); + const kept = compareTrees(treeOf(files(computed)), treeOf(files(computed))); + assert.deepEqual(kept.reasons, []); + assert.equal(kept.known.length, 1); +}); + +test("imports are read from code: comments and strings do not count, specifiers do", () => { + const trace = traceSchema( + treeOf({ + [`${MIGRATIONS}/001_A.ts`]: [ + "// each step must require (in order) the step before it", + "/* import x from './gone.ts'; import(name) */", + 'const note = "require (anything) or import (this)";', + "const re = /import\\(/;", + "const sql = `SELECT 1 -- require (x) ${'import(y)'}`;", + 'import { b } from "./b.ts"; // import { c } from "./c.ts";', + "const url = 'https://example.invalid/require(';", + ].join("\n"), + [`${MIGRATIONS}/b.ts`]: "export const b = 1;", + }), + ); + assert.deepEqual(trace.unresolved, []); + assert.ok(trace.closure.has(`${MIGRATIONS}/b.ts`)); + // A real computed import, also inside a template's interpolation, is still seen. + const computed = traceSchema( + treeOf({ [`${MIGRATIONS}/001_A.ts`]: "const s = `${require(name)}`;" }), + ); + assert.match(computed.unresolved.join(""), /require\(\.\.\.", but its target is computed/); + // A file whose comment or template never ends cannot be read, so it is reported. + const open = traceSchema( + treeOf({ [`${MIGRATIONS}/001_A.ts`]: 'const s = `never ends\nimport x from "./x.ts";' }), + ); + assert.match( + open.unresolved.join(""), + /001_A\.ts: a comment, string or template in it does not end/, + ); +}); + +test("e2e: the output names the imports from outside the repository that are not guarded", () => { + const result = prAgainstBase({ "apps/server/src/feature/Store.ts": "export const x = 2;\n" }); + assert.equal(result.code, 0, result.output); + assert.match( + result.output, + /Imports from outside this repository \(their dependency specs are guarded, their code is not\): effect\./, + ); +}); + +test("e2e: a change to the workspace package list is refused", () => { + const result = prAgainstBase( + { "pnpm-workspace.yaml": "packages:\n - packages/*\n - vendor/*\n" }, + PACKAGE_BASE, + ); + assert.equal(result.code, 1, result.output); + assert.match( + result.output, + /pnpm-workspace\.yaml: changes which directories are workspace packages/, + ); + const catalog = prAgainstBase( + { "pnpm-workspace.yaml": "packages:\n - packages/*\ncatalog:\n x: 1\n" }, + PACKAGE_BASE, + ); + assert.equal(catalog.code, 0, catalog.output); +}); + +// The importer's side: apps/server holds the migrations and depends on `@t3tools/c` and `effect`, +// which the migrations import; `other` is a dependency the schema code does not import. +const SPEC_BASE = { + ...PACKAGE_BASE, + "package.json": JSON.stringify({ name: "root", private: true }), + "pnpm-workspace.yaml": [ + "packages:", + " - apps/*", + " - packages/*", + "catalog:", + " effect: 4.1.2", + " other: 1.0.0", + "catalogs:", + " next:", + " effect: 4.2.0", + "overrides:", + ' "dbus-next>usocket": "-"', + "patchedDependencies:", + " other@1.0.0: patches/other@1.0.0.patch", + " effect@4.1.2: patches/effect@4.1.2.patch", + "", + ].join("\n"), + "patches/other@1.0.0.patch": "--- a\n+++ b\n", + "patches/effect@4.1.2.patch": "--- a\n+++ b\n", + [`${MIGRATIONS}/001_A.ts`]: + 'import { S } from "@t3tools/c";\nimport * as Sql from "effect/sql";\n', + "apps/server/package.json": JSON.stringify({ + name: "server", + dependencies: { effect: "catalog:", other: "catalog:" }, + devDependencies: { "@t3tools/c": "workspace:*" }, + }), +}; +const json = (fields) => JSON.stringify(fields); +const yamlWith = (from, to) => SPEC_BASE["pnpm-workspace.yaml"].replace(from, to); + +test("e2e: re-pointing a name the schema code imports from the importer's side is refused", () => { + const server = JSON.parse(SPEC_BASE["apps/server/package.json"]); + const tricks = { + "link: spec in the importer": { + "apps/server/package.json": json({ + ...server, + devDependencies: { "@t3tools/c": "link:../../x/evil" }, + }), + "x/evil/package.json": json({ name: "@t3tools/c", main: "index.ts" }), + "x/evil/index.ts": "export const S = 3;\n", + }, + "npm: alias in the importer": { + "apps/server/package.json": json({ + ...server, + devDependencies: { "@t3tools/c": "npm:left-pad@1.3.0" }, + }), + }, + "file: spec for an outside name": { + "apps/server/package.json": json({ + ...server, + dependencies: { ...server.dependencies, effect: "file:../../x/effect" }, + }), + }, + "the name added to the root manifest": { + "package.json": json({ name: "root", dependencies: { effect: "link:./x/effect" } }), + }, + "workspace overrides": { + "pnpm-workspace.yaml": yamlWith( + "overrides:\n", + 'overrides:\n "@t3tools/c": "link:./x/evil"\n', + ), + }, + "a nested override of the name": { + "pnpm-workspace.yaml": yamlWith("overrides:\n", "overrides:\n foo>effect@^4: 4.0.0\n"), + }, + catalog: { "pnpm-workspace.yaml": yamlWith(" effect: 4.1.2", " effect: link:./x/effect") }, + "named catalog": { + "pnpm-workspace.yaml": yamlWith(" effect: 4.2.0", " effect: npm:evil@1"), + }, + "a patch of the name re-pointed": { + "pnpm-workspace.yaml": yamlWith( + "effect@4.1.2: patches/effect@4.1.2.patch", + "effect@4.1.2: patches/effect-fork.patch", + ), + "patches/effect-fork.patch": "--- a\n+++ b\n", + }, + "root pnpm.overrides": { + "package.json": json({ name: "root", pnpm: { overrides: { effect: "link:./x" } } }), + }, + "root resolutions": { + "package.json": json({ name: "root", resolutions: { "**/@t3tools/c": "link:./x" } }), + }, + "the importer's package.json made unreadable": { "apps/server/package.json": "{ broken" }, + }; + const passed = Object.entries(tricks).filter(([, files]) => { + const result = prAgainstBase(files, SPEC_BASE); + return !(result.code === 1 && /changes what "[^"]+" resolves to/.test(result.output)); + }); + assert.deepEqual( + passed.map(([trick]) => trick), + [], + ); + // The patch file of a name the schema code imports is guarded like schema code. + const patched = prAgainstBase( + { "patches/effect@4.1.2.patch": "--- a\n+++ b\n@@ fork @@\n" }, + SPEC_BASE, + ); + assert.equal(patched.code, 1, patched.output); + assert.match(patched.output, /patches\/effect@4\.1\.2\.patch: changes the database schema/); +}); + +test("e2e: dependency changes of names the schema code does not import stay free", () => { + const server = JSON.parse(SPEC_BASE["apps/server/package.json"]); + const result = prAgainstBase( + { + "pnpm-workspace.yaml": yamlWith(" other: 1.0.0", " other: 1.1.0\n added: 2.0.0").replace( + "overrides:\n", + "overrides:\n other: 1.1.0\n", + ), + "patches/other@1.0.0.patch": "--- a\n+++ b\n@@ changed @@\n", + "apps/server/package.json": json({ + ...server, + dependencies: { ...server.dependencies, other: "2.0.0", added: "1.0.0" }, + }), + // A package that holds no schema code may depend on the guarded names as it likes. + "packages/d/package.json": json({ name: "@t3tools/d", dependencies: { effect: "link:x" } }), + }, + SPEC_BASE, + ); + assert.equal(result.code, 0, result.output); +}); + +test("e2e: a package.json is judged by the entries schema code imports through it", () => { + const base = { + ...SPEC_BASE, + [`${MIGRATIONS}/001_A.ts`]: 'import { U } from "@t3tools/c/util";\n', + "packages/c/package.json": manifest({ + exports: { ".": "./src/index.ts", "./util": "./src/util.ts", "./ui/*": "./src/ui/*.ts" }, + }), + "packages/c/src/util.ts": "export const U = 1;\n", + }; + const exportsWith = (exports) => ({ "packages/c/package.json": manifest({ exports }) }); + const kept = { ".": "./src/index.ts", "./util": "./src/util.ts", "./ui/*": "./src/ui/*.ts" }; + const free = [ + // A new module, an edit of an entry no schema code imports, unrelated fields. + { + ...exportsWith({ ...kept, "./newThing": "./src/newThing.ts" }), + "packages/c/src/newThing.ts": "", + }, + exportsWith({ ...kept, ".": "./src/settings.ts" }), + { + "packages/c/package.json": manifest({ exports: kept, scripts: { test: "x" }, version: "2" }), + }, + ]; + for (const files of free) { + const result = prAgainstBase(files, base); + assert.equal(result.code, 0, result.output); + } + const refused = [ + exportsWith({ ...kept, "./util": "./src/settings.ts" }), + exportsWith({ ...kept, "./util": { node: "./src/evil.ts", default: "./src/util.ts" } }), + // A pattern added in front of the entry, or the entry turned into a pattern. + exportsWith({ ".": "./src/index.ts", "./*": "./src/*.ts" }), + { "packages/c/package.json": manifest({ exports: kept, imports: { "#u": "./x.ts" } }) }, + { "packages/c/package.json": JSON.stringify({ name: "@t3tools/other", exports: kept }) }, + { "packages/c/package.json": null }, + ]; + for (const files of refused) { + const result = prAgainstBase(files, base); + assert.equal(result.code, 1, `${JSON.stringify(files)}\n${result.output}`); + assert.match(result.output, /packages\/c\/package\.json: changes how it maps "\.\/util"/); + } +}); diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml new file mode 100644 index 0000000000000..e755470fe5993 --- /dev/null +++ b/.github/workflows/fork-checks.yml @@ -0,0 +1,44 @@ +name: Fork checks + +# Rules for this fork's own changes, run on every pull request. Upstream's CI needs runners a fork +# does not have; fork-tests.yml runs upstream's tests on hosted runners instead. +# +# pull_request_target, not pull_request: GitHub then runs this workflow file and the check script +# from the default branch, so a pull request cannot weaken, skip or delete the check that judges +# it. The pull request's commits are only fetched as git objects and read as text; nothing from +# them is checked out or run, so the base branch's permissions never reach pull request code. +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +jobs: + no_schema_changes: + name: No database schema changes + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout the default branch + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Fetch the pull request's commits (not checked out) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: git fetch --no-tags --filter=blob:none origin "$BASE_SHA" "$HEAD_SHA" + + - name: Test the check + run: node --test .github/scripts/fork-schema-check.test.cjs + + # T3 has no down-migrations, so a fork migration strands installs that return to upstream. + - name: Refuse schema and migration changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node .github/scripts/fork-schema-check.cjs "$BASE_SHA" "$HEAD_SHA" From 36d66b8964bb861a060726af4a47713ed78474a8 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:03:03 +0100 Subject: [PATCH 05/27] fork: green main again (schema-check comments, checkout without submodule cleanup) (#8) * fork: green main again (schema-check comments, checkout without submodule cleanup) Lint: eight comments in fork-schema-check.cjs were over the four-line cap of t3code/no-low-value-comments. Cut to the constraints; the restating parts and JSDoc type blocks are gone (the file is not type-checked). No database schema changes: actions/checkout with persist-credentials: false runs `git submodule foreach` to remove auth, and upstream's tree has gitlinks under .repos/alchemy-effect/submodules/ with no .gitmodules, so it died with "No url found for submodule path" before any script ran. The job now uses plain git: init, an anonymous blob:none fetch of the default branch, base and head, a sparse checkout of .github at github.sha. Still pull_request_target, still no checkout of PR code, any git error fails the step. Tests: a gitlink with no .gitmodules in the e2e set, and the workflow's own checkout step run against such a repository (plus fail-closed on a missing commit). The workflow shape test now allows only a checkout of github.sha. Co-Authored-By: Claude Opus 5.5 * fork-checks: full checkout of the default branch, so the check does not fetch blobs one by one A sparse checkout left every blob the check reads to a separate lazy fetch from the partial clone (about 170 round trips on the real tree), close to the job's five-minute timeout. A full checkout fetches the default branch's blobs in one batch, as actions/checkout did before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/scripts/fork-schema-check.cjs | 85 +++++--------------- .github/scripts/fork-schema-check.test.cjs | 91 ++++++++++++++++++++-- .github/workflows/fork-checks.yml | 22 +++--- 3 files changed, 118 insertions(+), 80 deletions(-) diff --git a/.github/scripts/fork-schema-check.cjs b/.github/scripts/fork-schema-check.cjs index 3f98dbcf40a34..21ba11a6359c8 100644 --- a/.github/scripts/fork-schema-check.cjs +++ b/.github/scripts/fork-schema-check.cjs @@ -1,12 +1,7 @@ // Refuses fork changes to the database schema: T3 has no down-migrations, so a fork migration -// strands installs that return to upstream. Usage: fork-schema-check.cjs . -// Refused: migrations, schema setup and what they import; a change to how those imports map to -// files (the package.json fields that map them, the dependency specs, catalogs and overrides of -// the imported names, the workspace package list); a new import of theirs that should resolve -// inside the repository but does not; shipped source files holding a schema statement; a PR copy -// of this check or of .gitattributes that differs from the base's copy; and a branch holding -// commits already on the base (built on an old, since rebased base, or copying its commits). -// Files are read as git blobs. +// strands installs that return to upstream. Refused: schema code and anything that changes what its +// imports resolve to, a PR copy of this check or .gitattributes, and branches holding commits +// already on the base. Files are read as git blobs. Usage: fork-schema-check.cjs . const { execFileSync } = require("node:child_process"); const path = require("node:path"); @@ -75,25 +70,10 @@ const isTest = (file) => TEST_SOURCE.test(file); const isShippedSource = (file) => SHIPPED_SOURCE.test(file) && !isTest(file); /** - * Files the schema roots import, transitively: relative imports, and for an import of a workspace - * package every file any export condition of that entry can pick. For a package root - * (`@t3tools/contracts`) only the files that declare the imported names are followed. The barrels - * on the way to a declaring file are guarded too (a barrel edit could re-point a name), but their - * other re-exports are not followed, so the rest of the package stays free to change. A name that - * cannot be traced guards the whole barrel and everything it re-exports. - * - * Besides the files: `mappings` names, per package.json, the entries (`.`, `./sub`) the schema - * code imports through it (only those fields are compared, so a new export elsewhere stays free); - * `imported` holds every package name the schema code imports, from the workspace or outside, and - * `holders` the package.json files of the packages holding schema code, plus the root one: their - * dependency specs for those names decide what the names resolve to. - * - * Nothing that could be schema code is skipped silently: an import inside the repository that - * resolves to no file is listed in `unresolved` (the check refuses it when the PR brings it), and - * only packages outside the workspace resolve to nothing; their names are listed in `outside`. - * @param {{ files: string[]; has(file: string): boolean; read(file: string): string }} tree - * @returns {{ closure: Set; unresolved: string[]; outside: Set; - * imported: Set; mappings: Map>; holders: Set }} + * Files the schema roots import, transitively, plus what maps those imports (`mappings`, `imported`, + * `holders`). Barrels on the way to a declaring file are guarded (an edit could re-point a name), + * but their other re-exports are not followed. Nothing is skipped silently: an in-repo import with + * no file goes in `unresolved`; only packages outside the workspace go in `outside`. */ function traceSchema(tree) { const queue = tree.files.filter( @@ -409,11 +389,8 @@ function importedNames(source) { } /** - * The files an import reaches: the files it maps to, or for a package root the files declaring - * `names` (`files`, whose own imports matter) and the barrels re-exporting them (`barrels`, guarded - * only). A workspace package's package.json is guarded through `ctx.guard`; an import that should - * resolve inside the repository but does not is reported through `ctx.unresolved`. - * @returns {{ files: string[]; barrels: string[] }} + * The files an import reaches. For a package root: the files declaring `names` (`files`, whose own + * imports matter) and the barrels re-exporting them (`barrels`, guarded only). */ function resolveImport(ctx, from, specifier, names) { const { tree } = ctx; @@ -483,11 +460,8 @@ function resolveImport(ctx, from, specifier, names) { } /** - * Every path a package.json can map `name[/subpath]` to, for any export condition (`node`, - * `import`, `types`, `default`, custom ones, nested or in fallback arrays): the check does not - * guess which condition a tool picks, so it guards them all. Without `exports`: `main`, `module`, - * `types`, a string `browser` and `index`, or the subpath itself. - * @returns {string[]} + * Every path a package.json can map `name[/subpath]` to, under any export condition, nested or in + * fallback arrays: the check does not guess which condition a tool picks, so it guards them all. */ function packageTargets(manifest, subpath) { const key = subpath ? `./${subpath}` : "."; @@ -540,11 +514,9 @@ function exportEntries(exports, key) { } /** - * What a package.json says about the import `key` (`.` or `./sub`): its name, type, the export - * entries that answer that key (and whether `exports` is a subpath map), the fallback fields and - * `imports`. Two trees whose values differ map the import differently; other export entries do not - * count, so adding a module to the package stays free. A file that is absent or not JSON gives - * a value of its own. + * What a package.json says about the import `key` (`.` or `./sub`). Other export entries do not + * count, so adding a module to the package stays free. An absent or non-JSON file gives a value of + * its own, so it never compares equal to a real mapping. */ function manifestMapping(text, key) { if (text === undefined) return "(absent)"; @@ -604,13 +576,9 @@ function yamlEntries(text) { } /** - * The dependency specs that decide what the imported `names` resolve to: their entries in the - * dependency fields of the `holders` (the package.json files of the packages holding schema code, - * and the root one), the root's `pnpm.overrides`, `pnpm.patchedDependencies` and `resolutions`, - * and pnpm-workspace.yaml's `catalog`, `catalogs`, `overrides` and `patchedDependencies`. Specs of - * other names are not read, so other dependency bumps stay free. `patches` are the patch files of - * those names, guarded like schema code. - * @returns {{ specs: Map; patches: string[] }} + * The specs that decide what the imported `names` resolve to (the `holders`' dependency fields, root + * overrides and resolutions, pnpm-workspace.yaml catalogs and overrides). Specs of other names are + * not read, so other dependency bumps stay free. `patches` of those names are guarded like schema. */ function dependencySpecs(tree, names, holders) { const specs = new Map(); @@ -785,13 +753,9 @@ function resolveFile(tree, file) { } /** - * @param {ReadonlyArray<{ path: string; before?: string; after?: string; sameAsBase?: boolean; fromOldMain?: boolean }>} changes - * one entry per path the branch changes since the merge-base; `before`/`after` are the file - * texts (undefined when absent); `sameAsBase` says the branch's copy equals the base's copy; - * `fromOldMain` says the branch's copy is the one a commit already on main left there - * @param {{ closure: ReadonlySet; baseHasCheck: boolean; staleCommits?: number }} context - * `staleCommits`: the branch's commits that the base already holds as rebased copies - * @returns {string[]} one reason per refused file + * One reason per refused file. `changes`: one entry per path changed since the merge-base, with its + * `before`/`after` texts (undefined when absent), `sameAsBase` (equals the base's copy) and + * `fromOldMain` (a commit already on main left it). `staleCommits`: commits the base holds rebased. */ function findSchemaChanges(changes, { closure, baseHasCheck, staleCommits = 0 }) { const reasons = []; @@ -930,14 +894,7 @@ function staleCommits(mergeBase, base, head) { .map((commit) => commit.sha); } -/** - * Judges what the branch changes in how schema code is found, between the merge-base tree and the - * head tree: the guarded closure (both trees), imports the head cannot follow that the merge-base - * could (`known`: the ones already on the merge-base, which the branch did not bring), and changes - * to the workspace list, to the package.json fields that map schema imports, and to the dependency - * specs of the names schema code imports. - * @returns {{ closure: Set; reasons: string[]; known: string[]; outside: string[] }} - */ +/** Judges what the branch changes in how schema code is found: merge-base tree against head tree. */ function compareTrees(before, after) { const trees = [before, after]; const traces = trees.map(traceSchema); diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs index cd66805db7a7a..4d6e3ebe7b017 100644 --- a/.github/scripts/fork-schema-check.test.cjs +++ b/.github/scripts/fork-schema-check.test.cjs @@ -443,9 +443,8 @@ test("workspace packages come from the workspace list, scoped or not, at any dep assert.deepEqual([...trace.outside].sort(), ["@t3tools/skipped", "effect", "node:fs"]); }); -test("the workflow pins actions by commit SHA", () => { +test("any action the workflow uses is pinned by commit SHA", () => { const uses = fs.readFileSync(WORKFLOW, "utf8").match(/^\s*(?:-\s*)?uses:\s*\S+/gm) ?? []; - assert.ok(uses.length > 0); for (const line of uses) assert.match(line, /@[0-9a-f]{40}$/, line); }); @@ -454,10 +453,12 @@ test("the workflow runs the default branch's copy and never runs pull request co const code = workflow.replace(/^\s*#.*$/gm, ""); assert.match(code, /^on:\n {2}pull_request_target:/m); assert.doesNotMatch(code, /^\s+pull_request:/m); - // No checkout of the pull request: actions/checkout without `ref` takes the default branch. - assert.doesNotMatch(code, /\bref:/); - assert.doesNotMatch(code, /git (checkout|switch|worktree|reset)/); - assert.match(code, /persist-credentials: false/); + // The only checkout is of the default branch (`github.sha` under pull_request_target). + assert.doesNotMatch(code, /actions\/checkout|\bref:|secrets\.|GITHUB_TOKEN/); + assert.deepEqual(code.match(/git (checkout|switch|worktree|reset|read-tree)\b.*/g), [ + 'git checkout -q --detach "$DEFAULT_SHA"', + ]); + assert.deepEqual(code.match(/DEFAULT_SHA:.*/g), ["DEFAULT_SHA: ${{ github.sha }}"]); // Event values reach the shell only through env, never pasted into the script. for (const line of code.split("\n").filter((l) => /run:/.test(l))) { assert.doesNotMatch(line, /\$\{\{/, line); @@ -505,7 +506,34 @@ function scratchRepo() { }); return { code: result.status, output: result.stdout + result.stderr }; }; - return { dir, git, commit, check }; + return { dir, env, git, commit, check }; +} + +// The workflow's checkout step, run as CI runs it, against `origin` (a local repository). +function runCheckoutStep(origin, shas) { + const workflow = fs.readFileSync(WORKFLOW, "utf8"); + const block = workflow.match(/^ {8}run: \|\n((?: {10}.*\n)+)/m); + assert.ok(block && /git init/.test(block[1]), "the first run block is the checkout step"); + const script = block[1].replace(/^ {10}/gm, ""); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-checks-runner-")); + const result = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", "-c", script], { + cwd: dir, + env: { + ...origin.env, + GITHUB_SERVER_URL: `file://${path.dirname(origin.dir)}`, + GITHUB_REPOSITORY: path.basename(origin.dir), + ...shas, + }, + encoding: "utf8", + }); + return { dir, code: result.status, output: result.stdout + result.stderr }; +} + +/** A gitlink with no .gitmodules, as upstream's tree has under .repos/alchemy-effect/submodules/. */ +function addGitlink(repo, file, sha) { + repo.git("update-index", "--add", "--cacheinfo", `160000,${sha},${file}`); + repo.git("commit", "-q", "-m", "gitlink"); + return repo.git("rev-parse", "HEAD"); } const BASE_TREE = { @@ -1101,3 +1129,52 @@ test("e2e: a package.json is judged by the entries schema code imports through i assert.match(result.output, /packages\/c\/package\.json: changes how it maps "\.\/util"/); } }); + +test("e2e: a tree with a gitlink and no .gitmodules is checked like any other", () => { + const repo = scratchRepo(); + try { + repo.commit(BASE_TREE); + const base = addGitlink(repo, ".repos/vendor/submodules/dep", "1".repeat(40)); + const head = repo.commit({ "apps/server/src/feature/Store.ts": "export const x = 2;\n" }); + const bumped = addGitlink(repo, ".repos/vendor/submodules/dep", "2".repeat(40)); + for (const result of [repo.check(base, head), repo.check(base, bumped)]) { + assert.equal(result.code, 0, result.output); + assert.match(result.output, /No database schema changes/); + } + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test("e2e: the workflow's checkout works on a tree with gitlinks and no .gitmodules", () => { + const origin = scratchRepo(); + origin.git("config", "uploadpack.allowFilter", "true"); + origin.git("config", "uploadpack.allowAnySHA1InWant", "true"); + const runners = []; + try { + origin.commit(BASE_TREE); + const main = addGitlink(origin, ".repos/vendor/submodules/dep", "1".repeat(40)); + const pr = origin.commit({ "apps/server/src/feature/Store.ts": "export const x = 2;\n" }); + origin.git("checkout", "-q", "--detach", main); + + const run = runCheckoutStep(origin, { DEFAULT_SHA: main, BASE_SHA: main, HEAD_SHA: pr }); + runners.push(run.dir); + assert.equal(run.code, 0, run.output); + const git = (...args) => + execFileSync("git", args, { cwd: run.dir, env: origin.env, encoding: "utf8" }).trim(); + assert.equal(git("rev-parse", "HEAD"), main, "the default branch is checked out, not the PR"); + assert.equal(git("cat-file", "-t", pr), "commit", "the PR head is fetched as objects"); + assert.ok(fs.existsSync(path.join(run.dir, CHECK_FILES[0]))); + + // Fail closed: a commit the remote does not have stops the job. + const missing = runCheckoutStep(origin, { + DEFAULT_SHA: main, + BASE_SHA: main, + HEAD_SHA: "3".repeat(40), + }); + runners.push(missing.dir); + assert.notEqual(missing.code, 0, missing.output); + } finally { + for (const dir of [origin.dir, ...runners]) fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index e755470fe5993..46f0e986a2def 100644 --- a/.github/workflows/fork-checks.yml +++ b/.github/workflows/fork-checks.yml @@ -20,18 +20,22 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Checkout the default branch - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - fetch-depth: 0 - filter: blob:none - persist-credentials: false - - - name: Fetch the pull request's commits (not checked out) + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ but no + # .gitmodules, and checkout's credential cleanup runs `git submodule foreach`, which fails on + # them. The full checkout fetches the default branch's blobs in one batch; a sparse one leaves + # the check to fetch every blob it reads one at a time. + - name: Check out the default branch and fetch the pull request's commits (not checked out) env: + DEFAULT_SHA: ${{ github.sha }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: git fetch --no-tags --filter=blob:none origin "$BASE_SHA" "$HEAD_SHA" + run: | + set -euo pipefail + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + git fetch -q --no-tags --no-recurse-submodules --filter=blob:none origin \ + "$DEFAULT_SHA" "$BASE_SHA" "$HEAD_SHA" + git checkout -q --detach "$DEFAULT_SHA" - name: Test the check run: node --test .github/scripts/fork-schema-check.test.cjs From 8b0ad2c1ceeb6555c120749e8a52ea7a8ef2fffa Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:11:07 +0100 Subject: [PATCH 06/27] fork: follow each stable upstream release with the patch series (#4) * fork: follow each stable upstream release with the patch series fork-follow.yml runs daily. When upstream has a newer stable tag it rebases the series onto it (patches upstream now has drop out as empty), runs the schema check, tests, builds and publishes through fork-release.yml, then moves the series branch with a lease. When the series has commits no fork release has, it releases them. A conflict, schema change, upstream commits inside the series, a red test or a failed build open or update one issue and publish nothing. Pushing a rebase needs a FORK_PUSH_TOKEN secret, because GITHUB_TOKEN may not push commits that change workflow files. Co-Authored-By: Claude Opus 5.5 * fork-follow: report from real state, guarded pushes, upstream workflows off Review round 1 on the follow-upstream workflow. - The plan picks one of five actions: none, release, rebase, advance (the rebased series is already released; only move main) and stage (the rebase changes the release workflow itself, so the candidate is pushed and a person starts its release). - Merge commits on main are refused instead of being flattened by a rebase; deleted or moved upstream tags and failed fetches give a reason and the right advice. - All pushes go through fork-follow-push.sh with FORK_PUSH_TOKEN only. Every run checks the token first (missing, refused, no push access). Moving main uses a lease, treats "already there" as success, and first keeps the old series as tag fork-follow/pre--. - fork-workflows.sh disables upstream's workflows and cancels their runs after each push and at the end of the run. - The report job runs on every run and reads branches, tags and releases back from the repository instead of guessing from job results. Its summary is the report; an issue is opened, commented on or closed only when the repository has Issues enabled. - fork-follow.test.sh covers all of this against local git remotes and a fake gh, plus static checks on the workflow file (secrets passed by name, token only in push steps, persist-credentials false, pinned actions). The plan job runs it first. Co-Authored-By: Claude Opus 5.5 * fork-follow: the pushing jobs run in the Environment fork-push A repository secret can be read by a workflow file on any pushed branch. An Environment secret limited to the series branch reaches only runs from that branch, so the plan and advance jobs (the only ones that read FORK_PUSH_TOKEN) now run in the Environment fork-push. When the token is empty there, the check fails and its reason (in the run's summary) names the Environment, how to limit it to the series branch, and why the token should not stay a repository secret. A test checks that exactly the jobs that read the token use the Environment. Co-Authored-By: Claude Opus 5.5 * fork-follow tests: tagless history and a refused candidate push Pin down two state cases from review round 1: upstream without a stable tag, or a series that contains none, is refused with a reason and plans nothing; a refused candidate push leaves git's reason in the report. Co-Authored-By: Claude Opus 5.5 * fork-follow: guard the token's isolation, compare release workflows on every path The Guard job (no Environment) runs before any job gets FORK_PUSH_TOKEN and refuses unless the repository holds no repository or organisation secret of that name (it sees only whether `secrets.FORK_PUSH_TOKEN` is empty there) and the Environment fork-push exists with exactly one deployment rule: the branch main. Each refusal says what to change. The plan compares the release workflows of the run's commit with the candidate's on the release path too, so a re-run or queued run of an older commit stages instead of building with other release steps. A commit missing from the fork is refused. The sweep cancels unfinished upstream runs from any event, names upstream runs that finished since the run began, and names upstream workflow files GitHub does not list yet. The report shows both. Nits: a crashing schema check is reported as a crash; the conflict advice says to start the follow workflow after the manual push; `while read` instead of an unquoted `for`; the ERR trap reports only from the main shell; action none deletes a leftover candidate branch. Co-Authored-By: Claude Opus 5.5 * fork-follow: guard per run attempt, refuse the branch move while a new upstream workflow is unlisted "Re-run failed jobs" and "Re-run job" keep the Guard job's result from an earlier attempt without running it again. The guard now outputs the attempt it passed in (set only after the guard script), and plan and advance run only when that is this attempt: a job-level if, so a skipped job never reaches the Environment. The report says to re-run all jobs. Before the branch move, advance checks the Environment's rule again (fork-follow-guard.sh --environment-only), because the release can take an hour or more. The Environment ties the token to the branch main, not to fork-follow.yml, so any workflow file on main may ask for it. The plan names the upstream workflow files that are new on the target release (a rename counts as new). Advance sweeps before the push, and refuses to move main while one of them is in the candidate and GitHub does not list it, since it cannot be disabled first. The reason says how a person moves main, or that a fork patch can delete the files. The sweep names only the new files as unregistered (with a warning). Unlisted files from earlier releases, most of which only run on events the fork never sends, go to unregistered_old and into a collapsed note in the summary. Tests: the plan's only job-level if is this attempt's guard (always(), !cancelled() and failure() turn it red); advance needs this attempt's guard; a pattern series branch with the matching rule is refused; new vs old unlisted files; the refusal before the move; the report on a stale guard. Co-Authored-By: Claude Opus 5.5 * fork-follow: scripts reach gh only in a run or under the tests' stub A test run against other scripts made a real gh API sweep and disabled a workflow of the live fork. Now every script that calls gh sources fork-gh-guard.sh: outside a GitHub Actions run it refuses unless a test runs it (FORK_TEST_MODE) with the stub as the gh on PATH, no GitHub token and a .invalid GH_HOST. It exits before any gh call, and every later gh call checks again. The tests install the stub through fork-test-gh.sh, which also empties the gh config, so even a script without the guard finds nothing to log in to. Co-Authored-By: Claude Opus 5.5 * fork-release: reach gh only in a run or under the tests' stub fork-release.sh sources fork-gh-guard.sh like the follow scripts, and fork-release.test.sh installs its stub through fork-test-gh.sh, with tests that a run without the stub, by hand, with a token or with a real GH_HOST refuses before any gh call. Co-Authored-By: Claude Opus 5.5 * fork-follow: a fork-*.yml is the fork's own even before main has it The sweep took only the files the series adds as the fork's own, so a fork pull request that adds a fork-*.yml had that workflow disabled (and its runs cancelled) by the next sweep. Now every .github/workflows/fork-*.y[a]ml is the fork's own. In return the plan refuses an upstream release that has such a file and the candidate keeps it, since the sweep would never disable it. Co-Authored-By: Claude Opus 5.5 * fork-follow: changed and non-ASCII upstream workflows hold the move too new_workflows read `git diff --name-only` with git's quoting, so a new upstream workflow with a non-ASCII name came out quoted, failed the .yml filter and slipped past the branch-move refusal. It now reads the diff NUL-separated with core.quotepath=off, and refuses a name with ',' or a newline (the lists are ','-joined). It also takes changed files (--diff-filter=AM): an unlisted upstream workflow that now runs on push holds the move like a new one. The lists are built in the plan's own shell, so a refusal stops the plan. Co-Authored-By: Claude Opus 5.5 * fork-follow tests: an || in advance's if:, continue-on-error on a refusal Three breaks left every test green: `||` after the attempt check in advance's if:, and continue-on-error on the Environment re-check or on the sweep before the move (the refusal turns into a warning and the branch moves). The tests now read advance's if: as one conjunction (no `||` outside parentheses) and allow continue-on-error only in the Report job. Co-Authored-By: Claude Opus 5.5 * fork-secret-scan: fail when gitleaks logs an error and still exits 0 gitleaks 8.30.1 logs `ERR error="stderr is not empty"` when git writes to stderr while it reads the log, may drop the rest of the diffs, and exits 0. The scan now fails (2) on any ERR line in gitleaks' log, before the commit count. Test: a git wrapper that writes a stray stderr line; with a 3,000-file commit the real gitleaks misses a key in the last file and passes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: t3code-fork Co-authored-by: Claude Opus 5.5 --- .github/scripts/fork-follow-guard.sh | 104 ++++ .github/scripts/fork-follow-push.sh | 128 ++++ .github/scripts/fork-follow-report.sh | 206 ++++++ .github/scripts/fork-follow.sh | 321 ++++++++++ .github/scripts/fork-follow.test.sh | 757 +++++++++++++++++++++++ .github/scripts/fork-gh-guard.sh | 51 ++ .github/scripts/fork-release.sh | 2 + .github/scripts/fork-release.test.sh | 37 +- .github/scripts/fork-secret-scan.sh | 9 + .github/scripts/fork-secret-scan.test.sh | 38 ++ .github/scripts/fork-test-gh.sh | 28 + .github/scripts/fork-workflows.sh | 137 ++++ .github/workflows/fork-follow.yml | 413 +++++++++++++ 13 files changed, 2226 insertions(+), 5 deletions(-) create mode 100755 .github/scripts/fork-follow-guard.sh create mode 100755 .github/scripts/fork-follow-push.sh create mode 100755 .github/scripts/fork-follow-report.sh create mode 100755 .github/scripts/fork-follow.sh create mode 100755 .github/scripts/fork-follow.test.sh create mode 100644 .github/scripts/fork-gh-guard.sh create mode 100644 .github/scripts/fork-test-gh.sh create mode 100755 .github/scripts/fork-workflows.sh create mode 100644 .github/workflows/fork-follow.yml diff --git a/.github/scripts/fork-follow-guard.sh b/.github/scripts/fork-follow-guard.sh new file mode 100755 index 0000000000000..e5aa78543e46e --- /dev/null +++ b/.github/scripts/fork-follow-guard.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Guard job of fork-follow.yml (tests: fork-follow.test.sh). It runs before any job that gets +# FORK_PUSH_TOKEN and refuses unless the token's isolation holds as the repository is set up now: +# +# 1. No repository or organisation secret named FORK_PUSH_TOKEN. A workflow file on any pushed +# branch can read such a secret, and in a job with an Environment GitHub falls back to it when +# the Environment has none. The job runs without an Environment, so `secrets.FORK_PUSH_TOKEN` +# there can only be a repository or organisation secret; the workflow passes only whether it is +# empty (REPO_SECRET_PRESENT=true|false), never the value. GITHUB_TOKEN has no permission that +# lists secrets, so this is the check GitHub allows. +# 2. The Environment FORK_PUSH_ENVIRONMENT exists, and only SERIES_BRANCH may deploy to it: its +# deployment branches are "Selected branches and tags" with exactly one rule, the branch +# SERIES_BRANCH, written without wildcards. "Protected branches only" is refused, because the +# API cannot prove that no other branch is protected (or becomes protected later). +# +# `fork-follow-guard.sh --environment-only` makes check 2 alone. The job that moves the series +# branch runs it right before the push, because that can be an hour or more after the Guard job +# looked. Check 1 cannot be made there: inside the Environment, `secrets.FORK_PUSH_TOKEN` is the +# Environment's own secret. +# +# Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), FORK_PUSH_ENVIRONMENT, +# SERIES_BRANCH (default main), REPO_SECRET_PRESENT, REPORT (receives the reason on refusal). Reads +# the Environment with `gh api` (GH_TOKEN: GITHUB_TOKEN with actions: read is enough). +set -euo pipefail +# Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). +. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +: "${FORK_PUSH_ENVIRONMENT:?FORK_PUSH_ENVIRONMENT}" +SERIES_BRANCH="${SERIES_BRANCH:-main}" +REPORT="${REPORT:-fork-follow-report.md}" +env_name="$FORK_PUSH_ENVIRONMENT" +settings="Settings > Environments > $env_name > Deployment branches and tags" +err="$(mktemp)" +trap 'rm -f "$err"' EXIT + +die() { + printf '%s\n' "$@" > "$REPORT" + printf '::error::%s\n' "$1" >&2 + [[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2 + exit 1 +} + +case "${1:-}" in + "") secret_check=true ;; + --environment-only) secret_check=false ;; + *) echo "usage: $0 [--environment-only]" >&2; exit 2 ;; +esac + +[[ "$secret_check" == false ]] || case "${REPO_SECRET_PRESENT:-}" in + false) ;; + true) + die "A repository or organisation secret named FORK_PUSH_TOKEN exists, so this run does not use the token." \ + "A workflow file on any pushed branch can read a repository or organisation secret, and a job in the Environment $env_name falls back to it when the Environment has none." \ + "Delete it under Settings > Secrets and variables > Actions > Repository secrets (or, for an organisation secret, remove $REPOSITORY from its repository access)." \ + "Keep the token only as an Environment secret of $env_name." ;; + *) + die "The run could not tell whether a repository secret FORK_PUSH_TOKEN exists (REPO_SECRET_PRESENT='${REPO_SECRET_PRESENT:-}'), so it does not use the token." \ + "This is a fault in fork-follow.yml: its guard job must pass \`secrets.FORK_PUSH_TOKEN != ''\`." ;; +esac + +if ! environment="$(gh api "repos/$REPOSITORY/environments/$env_name" 2> "$err")"; then + if grep -q "404" "$err"; then + die "The Environment $env_name does not exist, so this run does not use the token." \ + "Create it under Settings > Environments > New environment, named $env_name." \ + "Under Deployment branches and tags pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." \ + "Then add the secret FORK_PUSH_TOKEN there: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." + fi + die "Could not read the Environment $env_name of $REPOSITORY, so this run does not use the token. gh said:" \ + '```' "$(tail -n 5 "$err")" '```' +fi + +policy="$(jq -r 'if .deployment_branch_policy == null then "all" + elif .deployment_branch_policy.protected_branches then "protected" + elif .deployment_branch_policy.custom_branch_policies then "custom" + else "unknown" end' <<< "$environment")" +case "$policy" in + all) + die "Any branch may deploy to the Environment $env_name, so a workflow file on any pushed branch could get FORK_PUSH_TOKEN; this run does not use it." \ + "Under $settings pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." ;; + protected) + die "The Environment $env_name allows \"Protected branches only\", which the API cannot prove means only $SERIES_BRANCH; this run does not use FORK_PUSH_TOKEN." \ + "Under $settings pick \"Selected branches and tags\" instead and add one rule: branch $SERIES_BRANCH (nothing else)." ;; + custom) ;; + *) + die "The deployment branch rule of the Environment $env_name is not one this guard knows, so this run does not use FORK_PUSH_TOKEN." \ + "Under $settings pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." ;; +esac + +rules="$(gh api "repos/$REPOSITORY/environments/$env_name/deployment-branch-policies?per_page=100" --paginate \ + --jq '.branch_policies[] | "\(.type // "branch") \(.name)"' 2> "$err")" \ + || die "Could not read the deployment branch rules of the Environment $env_name, so this run does not use FORK_PUSH_TOKEN. gh said:" \ + '```' "$(tail -n 5 "$err")" '```' +if [[ "$rules" != "branch $SERIES_BRANCH" ]] || [[ "$SERIES_BRANCH" == *[][*?]* ]]; then + die "The Environment $env_name must let only the branch $SERIES_BRANCH deploy, so this run does not use FORK_PUSH_TOKEN. Its rules now:" \ + "$(if [[ -n "$rules" ]]; then sed 's/^\([a-z]*\) \(.*\)$/- \1 `\2`/' <<< "$rules"; else echo "- (none)"; fi)" "" \ + "Under $settings keep exactly one rule, branch $SERIES_BRANCH, and delete the others (a tag rule or a pattern lets other refs deploy too)." +fi + +if [[ "$secret_check" == true ]]; then + echo "FORK_PUSH_TOKEN is only an Environment secret, and only $SERIES_BRANCH may deploy to $env_name." +else + echo "Only $SERIES_BRANCH may deploy to $env_name (the repository secret was checked by the Guard job)." +fi diff --git a/.github/scripts/fork-follow-push.sh b/.github/scripts/fork-follow-push.sh new file mode 100755 index 0000000000000..ebbdc9040e704 --- /dev/null +++ b/.github/scripts/fork-follow-push.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# The pushes of fork-follow.yml, all made with FORK_PUSH_TOKEN, one subcommand each (tests: +# fork-follow.test.sh). Every refusal writes its reason to $REPORT, prints a ::error:: line and +# exits non-zero; nothing falls back to another token. +# +# check Fails unless FORK_PUSH_TOKEN is set, accepted by GitHub and may push here. +# candidate SHA Force-pushes SHA as the branch fork-follow/candidate. +# advance PREV SHA Moves SERIES_BRANCH from PREV to SHA with a lease. First keeps PREV as the tag +# fork-follow/pre--, so the old series stays reachable. +# Already at SHA (a re-run) is success; moved elsewhere is a refusal. +# cleanup Deletes the branch fork-follow/candidate; a failure is only a warning. +# +# Environment: FORK_PUSH_TOKEN, FORK_PUSH_ENVIRONMENT (the GitHub Environment that should hold the +# token; named when it is missing), REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), PUSH_URL +# (default https://github.com/$REPOSITORY.git), SERIES_BRANCH (default main), BASE_TAG (advance), +# REPORT. Output (candidate_pushed, moved, backup_tag) goes to $GITHUB_OUTPUT, else stdout. +# +# The token reaches git through GIT_CONFIG_* variables as an HTTP header: never in a URL, a command +# line or .git/config, so git's error messages (copied into reports) cannot contain it. +set -euo pipefail + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +PUSH_URL="${PUSH_URL:-https://github.com/$REPOSITORY.git}" +SERIES_BRANCH="${SERIES_BRANCH:-main}" +REPORT="${REPORT:-fork-follow-report.md}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +CANDIDATE_BRANCH=fork-follow/candidate +err="$(mktemp)" +trap 'rm -f "$err"' EXIT + +die() { + printf '%s\n' "$@" > "$REPORT" + printf '::error::%s\n' "$1" >&2 + [[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2 + exit 1 +} + +git_said() { printf '%s\n' '```' "$(tail -n 15 "$err")" '```'; } + +with_token() { + if [[ -z "${FORK_PUSH_TOKEN:-}" && -n "${FORK_PUSH_ENVIRONMENT:-}" ]]; then + die "The secret FORK_PUSH_TOKEN is missing in the Environment $FORK_PUSH_ENVIRONMENT (or the Environment does not exist yet), so this run cannot push." \ + "Under Settings > Environments, create $FORK_PUSH_ENVIRONMENT (a run that names a missing Environment makes an empty one with no rules)," \ + "limit its deployment branches to $SERIES_BRANCH, and add the secret FORK_PUSH_TOKEN there: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \ + "Keep it out of the repository secrets: a workflow file on any pushed branch can read those, while an Environment limited to $SERIES_BRANCH gives it only to runs from $SERIES_BRANCH." \ + "GITHUB_TOKEN cannot replace it: it may not push commits that change workflow files." + fi + [[ -n "${FORK_PUSH_TOKEN:-}" ]] \ + || die "The secret FORK_PUSH_TOKEN is missing, so this run cannot push." \ + "Add it under Settings > Secrets and variables > Actions: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \ + "GITHUB_TOKEN cannot replace it: it may not push commits that change workflow files." + local header + header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$FORK_PUSH_TOKEN" | base64 -w0)" + [[ "${GITHUB_ACTIONS:-}" != true ]] || echo "::add-mask::$header" + export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0="$header" + export GIT_TERMINAL_PROMPT=0 +} + +remote_ref() { # prints the remote's commit for a ref, or nothing; fails if the remote cannot be read + git ls-remote "$PUSH_URL" "$1" 2> "$err" | cut -f1 +} + +cmd_check() { + with_token + git ls-remote "$PUSH_URL" HEAD > /dev/null 2> "$err" \ + || die "GitHub refused FORK_PUSH_TOKEN: it is expired, revoked, or not valid for $REPOSITORY." \ + "Renew it (a fine-grained token for $REPOSITORY only, Contents and Workflows read and write) and update the secret. git said:" \ + "$(git_said)" + # A dry run asks for push access (receive-pack) without changing anything. + git push --dry-run "$PUSH_URL" "$(git rev-parse HEAD):refs/heads/fork-follow/token-check" > /dev/null 2> "$err" \ + || die "FORK_PUSH_TOKEN may not push to $REPOSITORY." \ + "Give the token Contents read and write (and Workflows read and write) on $REPOSITORY. git said:" "$(git_said)" + echo "FORK_PUSH_TOKEN is accepted and may push to $REPOSITORY." +} + +cmd_candidate() { + local sha="${1:?SHA}" + with_token + git push --force "$PUSH_URL" "$sha:refs/heads/$CANDIDATE_BRANCH" > /dev/null 2> "$err" \ + || die "Could not push the candidate ${sha:0:12} to $CANDIDATE_BRANCH." \ + "If the rebase brought workflow file changes, the token also needs Workflows read and write. git said:" "$(git_said)" + echo "candidate_pushed=true" >> "$OUTPUT" + echo "Pushed ${sha:0:12} to $CANDIDATE_BRANCH." +} + +cmd_advance() { + local prev="${1:?PREV}" sha="${2:?SHA}" now backup + : "${BASE_TAG:?BASE_TAG}" + with_token + now="$(remote_ref "refs/heads/$SERIES_BRANCH")" \ + || die "Could not read $SERIES_BRANCH of $REPOSITORY, so it was not moved. git said:" "$(git_said)" + if [[ "$now" == "$sha" ]]; then + echo "moved=already" >> "$OUTPUT" + echo "$SERIES_BRANCH is already at ${sha:0:12}." + return 0 + fi + [[ "$now" == "$prev" ]] \ + || die "$SERIES_BRANCH moved since the plan (now ${now:-missing}, planned from ${prev:0:12}), so it was not moved to ${sha:0:12}." \ + "A fork pull request merged meanwhile, or someone pushed it. The next run rebases again with that change." + backup="fork-follow/pre-$BASE_TAG-${prev:0:12}" + git push "$PUSH_URL" "$prev:refs/tags/$backup" > /dev/null 2> "$err" \ + || die "Could not keep the old series ${prev:0:12} as tag $backup, so $SERIES_BRANCH was not moved. git said:" "$(git_said)" + echo "backup_tag=$backup" >> "$OUTPUT" + git push --force-with-lease="refs/heads/$SERIES_BRANCH:$prev" "$PUSH_URL" "$sha:refs/heads/$SERIES_BRANCH" > /dev/null 2> "$err" \ + || die "Could not move $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12}." \ + "If a rule protects $SERIES_BRANCH, the token's user must be allowed to bypass it. The old series is kept as tag $backup. git said:" "$(git_said)" + echo "moved=true" >> "$OUTPUT" + echo "Moved $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12} (old series kept as tag $backup)." +} + +cmd_cleanup() { + with_token + local now + if ! now="$(remote_ref "refs/heads/$CANDIDATE_BRANCH")"; then + echo "::warning::Could not read $CANDIDATE_BRANCH; it may be left over (the next run force-pushes over it)." + elif [[ -n "$now" ]] && ! git push "$PUSH_URL" --delete "refs/heads/$CANDIDATE_BRANCH" > /dev/null 2> "$err"; then + echo "::warning::Could not delete $CANDIDATE_BRANCH (the next run force-pushes over it): $(tail -n 3 "$err" | tr '\n' ' ')" + fi + return 0 +} + +case "${1:-}" in + check) cmd_check ;; + candidate) cmd_candidate "${@:2}" ;; + advance) cmd_advance "${@:2}" ;; + cleanup) cmd_cleanup ;; + *) echo "usage: $0 check | candidate SHA | advance PREV SHA | cleanup" >&2; exit 2 ;; +esac diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh new file mode 100755 index 0000000000000..aacb431b5fd28 --- /dev/null +++ b/.github/scripts/fork-follow-report.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Report job of fork-follow.yml (tests: fork-follow.test.sh). Writes the run's summary and exits +# non-zero when the run needs a person, so trouble shows as a failed run even without issues. +# +# What was pushed and published is read back from the repository (branches, tags, releases), not +# inferred from job results, so the summary never claims a state it did not see. When the +# repository has Issues enabled it also opens or comments on one issue, "Fork cannot follow +# upstream", and closes it once a run succeeds; with Issues disabled it says so and opens none. +# +# Environment (from the run): REPOSITORY, SERIES_BRANCH, RUN_URL, ACTION, GUARD_RESULT, +# GUARD_ATTEMPT (the run attempt the guard passed in), RUN_ATTEMPT, PLAN_RESULT, RELEASE_RESULT, +# ADVANCE_RESULT, BASE_TAG, PREVIOUS_TAG, PREVIOUS_HEAD, CANDIDATE, RELEASED_TAG, DROPPED, DISABLED, +# CANCELLED, FINISHED, UNREGISTERED, UNREGISTERED_OLD (from the workflow sweeps), SWEEP_RESULT, +# REPORTS (directory of the jobs' reason files), ORIGIN (git URL of this fork, default GitHub). +# Summary to $GITHUB_STEP_SUMMARY, else stdout. +set -uo pipefail +# Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). +. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +ORIGIN="${ORIGIN:-https://github.com/$REPOSITORY.git}" +SERIES_BRANCH="${SERIES_BRANCH:-main}" +SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +ACTION="${ACTION:-}" +REPORTS="${REPORTS:-}" +title="Fork cannot follow upstream" +short() { [[ -n "$1" ]] && echo "\`${1:0:12}\`" || echo "(none)"; } + +failed=false +for result in "${GUARD_RESULT:-success}" "${PLAN_RESULT:-}" "${RELEASE_RESULT:-skipped}" "${ADVANCE_RESULT:-skipped}"; do + [[ "$result" == success || "$result" == skipped ]] || failed=true +done +[[ "${PLAN_RESULT:-}" == success ]] || failed=true +[[ "$ACTION" != stage ]] || failed=true +[[ "${SWEEP_RESULT:-success}" != failure ]] || failed=true + +# A re-run of failed jobs (or of one job) keeps the guard's result from an earlier attempt; the jobs +# that get FORK_PUSH_TOKEN skip themselves then. Say so whenever one of them was skipped for it. +stale_guard=false +if [[ "${GUARD_RESULT:-success}" == success && -n "${GUARD_ATTEMPT:-}" && -n "${RUN_ATTEMPT:-}" \ + && "$GUARD_ATTEMPT" != "$RUN_ATTEMPT" ]]; then + if [[ "${PLAN_RESULT:-}" == skipped ]] \ + || [[ "${ADVANCE_RESULT:-skipped}" == skipped && ( "$ACTION" == advance || "$ACTION" == rebase ) ]]; then + stale_guard=true + failed=true + fi +fi + +# The sweeps' lists arrive joined with ','; drop the empty parts and repeats. +tidy() { tr ',' '\n' <<< "$1" | sed '/^$/d' | awk '!seen[$0]++' | paste -sd, -; } +DISABLED="$(tidy "${DISABLED:-}")" +CANCELLED="$(tidy "${CANCELLED:-}")" +FINISHED="$(tidy "${FINISHED:-}")" +UNREGISTERED="$(tidy "${UNREGISTERED:-}")" +UNREGISTERED_OLD="$(tidy "${UNREGISTERED_OLD:-}")" + +state=() +row() { state+=("| $1 | $2 |"); } + +# --- what the repository holds now ----------------------------------------------------------- +if refs="$(git ls-remote "$ORIGIN" "refs/heads/$SERIES_BRANCH" refs/heads/fork-follow/candidate 'refs/tags/*' 2>&1)"; then + at() { awk -v ref="$1" '$2 == ref { print $1 }' <<< "$refs"; } + series_now="$(at "refs/heads/$SERIES_BRANCH")" + candidate_now="$(at refs/heads/fork-follow/candidate)" + if [[ -z "${CANDIDATE:-}" ]]; then + row "Series branch \`$SERIES_BRANCH\`" "at $(short "$series_now")" + elif [[ "$series_now" == "$CANDIDATE" && "${PREVIOUS_HEAD:-$CANDIDATE}" != "$CANDIDATE" ]]; then + row "Series branch \`$SERIES_BRANCH\`" "moved to $(short "$CANDIDATE") (was $(short "$PREVIOUS_HEAD"))" + elif [[ "$series_now" == "$CANDIDATE" ]]; then + row "Series branch \`$SERIES_BRANCH\`" "at $(short "$series_now"), unchanged" + else + row "Series branch \`$SERIES_BRANCH\`" "**not moved**: at $(short "$series_now")" + fi + row "Candidate branch \`fork-follow/candidate\`" "$([[ -n "$candidate_now" ]] && echo "at $(short "$candidate_now")" || echo "absent")" + backups="$(awk '$2 ~ "^refs/tags/fork-follow/pre-" && $2 !~ /\^\{\}$/ { sub("refs/tags/", "", $2); print $2 }' <<< "$refs" | paste -sd, -)" + [[ -z "$backups" ]] || row "Backups of earlier series (tags)" "${backups//,/, }" + # Fork release tags that point at the candidate (lightweight, or annotated and peeled). + tags="" + if [[ -n "${CANDIDATE:-}" ]]; then + tags="$(awk -v sha="$CANDIDATE" '$1 == sha { t = $2; sub("refs/tags/", "", t); sub(/\^\{\}$/, "", t); print t }' <<< "$refs" \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' | sort -uV)" + fi + if [[ -z "${CANDIDATE:-}" ]]; then + row "Release" "none: the plan did not pick a commit" + elif [[ -z "$tags" ]]; then + row "Release" "**none published** for $(short "$CANDIDATE")" + else + for tag in $tags; do + if draft="$(gh api "repos/$REPOSITORY/releases/tags/$tag" --jq .draft 2>&1)"; then + if [[ "$draft" == false ]]; then + if [[ "$tag" == "${RELEASED_TAG:-}" && "$ACTION" != release ]]; then + row "Release" "$tag, published earlier" + else + row "Release" "$tag **published** at $(short "$CANDIDATE")" + fi + else + row "Release" "$tag tag exists, release is still a draft (not offered to updaters)" + failed=true + fi + elif [[ "$draft" == *"Not Found"* || "$draft" == *"404"* ]]; then + row "Release" "tag $tag exists without a release" + failed=true + else + row "Release" "unknown: could not read the release of $tag" + failed=true + fi + done + fi +else + row "Repository state" "unknown: could not read the branches and tags of $REPOSITORY" + failed=true +fi +[[ -z "${DISABLED:-}" ]] || row "Upstream workflows disabled" "${DISABLED//,/, }" +[[ -z "${CANCELLED:-}" ]] || row "Their runs cancelled" "${CANCELLED//,/, }" +[[ -z "${FINISHED:-}" ]] || row "**Upstream runs that finished before a sweep could stop them**" "${FINISHED//,/, }" +[[ -z "${UNREGISTERED:-}" ]] \ + || row "**Upstream workflow files new or changed on this release that GitHub does not list yet, so not disabled** (the sweep after an event reaches them disables them; the branch move waits for that)" "${UNREGISTERED//,/, }" +[[ -z "${DROPPED:-}" ]] || row "Patches dropped (empty on $BASE_TAG: upstream has the change)" "${DROPPED//;/, }" + +reasons="" +if [[ "$stale_guard" == true ]]; then + reasons+="This is attempt $RUN_ATTEMPT of the run, but the guard passed in attempt $GUARD_ATTEMPT: \"Re-run failed jobs\" and \"Re-run job\" keep the guard's earlier result without checking again, so the jobs that get FORK_PUSH_TOKEN were skipped. Use \"Re-run all jobs\", or start a new run (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`)."$'\n\n' +fi +if [[ -n "$REPORTS" && -d "$REPORTS" ]]; then + for file in "$REPORTS"/*.md; do + [[ -f "$file" ]] && reasons+="$(cat "$file")"$'\n\n' + done +fi + +case "$ACTION" in + none) what="Nothing to do: the series is released${RELEASED_TAG:+ as $RELEASED_TAG}." ;; + release) what="Release the series on ${BASE_TAG:-?} as it is." ;; + rebase) what="Rebase the series from ${PREVIOUS_TAG:-?} onto ${BASE_TAG:-?}, release it, then move \`$SERIES_BRANCH\`." ;; + advance) what="Move \`$SERIES_BRANCH\` onto ${BASE_TAG:-?}: ${RELEASED_TAG:-?} already has the rebased series." ;; + stage) what="Push the candidate on ${BASE_TAG:-?}; its release needs its own workflow files." ;; + *) if [[ "${GUARD_RESULT:-success}" != success ]]; then + what="None: the guard refused, so no job got FORK_PUSH_TOKEN." + elif [[ "$stale_guard" == true ]]; then + what="None: the guard did not run in this attempt, so no job got FORK_PUSH_TOKEN." + else + what="The plan did not finish." + fi ;; +esac + +body="$(mktemp)" +{ + if [[ "$failed" == true ]]; then + echo "## $title" + else + echo "## Fork follows upstream" + fi + echo + echo "Plan: $what Run: ${RUN_URL:-?}" + echo + if [[ "$failed" == true ]]; then + if [[ -n "$reasons" ]]; then + printf '%s' "$reasons" + else + echo "A job failed without writing a reason (results: guard ${GUARD_RESULT:-success}, plan ${PLAN_RESULT:-?}, release ${RELEASE_RESULT:-skipped}, advance ${ADVANCE_RESULT:-skipped}); see the run." + echo + fi + fi + echo "| What | State now |" + echo "|---|---|" + printf '%s\n' "${state[@]}" + if [[ -n "$UNREGISTERED_OLD" ]]; then + echo + echo "
Upstream workflow files from earlier releases that GitHub does not list (not a warning)" + echo + echo "Most of them run only on events this fork never sends (workflow_call, workflow_dispatch, workflow_run), so they stay unlisted. A sweep disables one once an event reaches it: ${UNREGISTERED_OLD//,/, }" + echo + echo "
" + fi +} > "$body" + +# --- the issue, when the repository has Issues --------------------------------------------------- +note="" +if ! has_issues="$(gh api "repos/$REPOSITORY" --jq .has_issues 2>&1)"; then + note="Could not read whether $REPOSITORY has Issues, so no issue was opened or closed." + failed=true +elif [[ "$has_issues" != true ]]; then + note="Issues are disabled on $REPOSITORY, so no issue was opened or closed; this summary and the run's status are the report." +elif ! issue="$(gh issue list --repo "$REPOSITORY" --state open --search "\"$title\" in:title" \ + --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)"; then + note="Could not list the issues of $REPOSITORY, so no issue was opened or closed." + failed=true +elif [[ "$failed" == true && -n "$issue" ]]; then + gh issue comment "$issue" --repo "$REPOSITORY" --body-file "$body" > /dev/null \ + && note="Commented on issue #$issue." || { note="Could not comment on issue #$issue."; } +elif [[ "$failed" == true ]]; then + if url="$(gh issue create --repo "$REPOSITORY" --title "$title" --body-file "$body")"; then + note="Opened $url." + else + note="Could not open an issue." + fi +elif [[ -n "$issue" ]]; then + gh issue close "$issue" --repo "$REPOSITORY" --comment "$(cat "$body")" > /dev/null \ + && note="Closed issue #$issue." || { note="Could not close issue #$issue."; failed=true; } +fi + +{ + cat "$body" + [[ -z "$note" ]] || printf '\n%s\n' "$note" +} >> "$SUMMARY" +rm -f "$body" +[[ "$failed" != true ]] diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh new file mode 100755 index 0000000000000..227eb438fc6d4 --- /dev/null +++ b/.github/scripts/fork-follow.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# Plan step of fork-follow.yml: decides what the run does, and rebases the fork's patch series onto +# a newer stable upstream tag when there is one. Run in a full clone with the series checked out. +# It pushes and publishes nothing itself. Tests: fork-follow.test.sh. +# +# Environment: UPSTREAM (owner/name), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN +# (git remote or URL of this fork, default origin), RUN_SHA (the commit this run's workflow files +# come from, default HEAD), REPOSITORY (owner/name of this fork, for the advice in reports), REPORT +# (file that receives the reason when the run cannot go on). +# +# Output, key=value lines to $GITHUB_OUTPUT (stdout without it): +# action none the series head is the newest fork release of its base already +# release same base, but the series has commits no fork release has +# rebase rebased onto a newer tag: release the candidate, then move the series +# advance rebased onto a newer tag, and a published fork release already has the +# candidate's exact files: move the series to that release, build nothing +# stage the release workflows of this run's commit differ from the candidate's +# (upstream changed them, or this is a re-run of an older run): push the +# candidate and stop, so it is released with its own files (see $REPORT) +# base_tag, base_commit the upstream tag (and its commit) the candidate sits on +# previous_tag, previous_head where the series was (rebase, advance, stage) +# candidate the commit to release or to move the series to +# released_tag the fork release that already has the candidate's files (none, advance) +# dropped subjects of patches that became empty on the new base (';'-separated) +# own_workflows workflow files the series itself adds (','-separated); every other one is +# upstream's and gets disabled +# new_workflows upstream workflow files added or changed between the series' old base and the +# new one (','-separated; empty without a new base). The branch move refuses while +# one of them is not listed by GitHub yet, so it cannot be disabled before main has +# it (a changed file may now run on push). +set -Eeuo pipefail + +: "${UPSTREAM:?owner/name of the upstream repository}" +REPORT="${REPORT:-fork-follow-report.md}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +ORIGIN="${ORIGIN:-origin}" +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:-this repository}}" +logs="${RUNNER_TEMP:-$(mktemp -d)}" +upstream_url="${UPSTREAM_URL:-https://github.com/$UPSTREAM.git}" +reported="" + +fail() { + printf '%s\n' "$@" > "$REPORT" + cat "$REPORT" >&2 + reported=1 + exit 1 +} + +# A command that fails outside fail() (set -e) still leaves a reason that names the step. +on_error() { + local status="$1" line="$2" command="$3" + # Only the script's own shell reports: a command substitution inherits the trap (set -E), and a + # failure there that matters fails the line that uses it, which is reported here. + ((BASH_SUBSHELL == 0)) || return 0 + [[ -z "$reported" ]] || return 0 + reported=1 + printf '%s\n' "The plan stopped at fork-follow.sh line $line (exit $status): \`$command\`" \ + "This is not one of the planned refusals; see the run log." > "$REPORT" + cat "$REPORT" >&2 +} +trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR + +stable_re='^v[0-9]+\.[0-9]+\.[0-9]+$' +fork_re='^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' +head="$(git rev-parse HEAD)" +run_sha="${RUN_SHA:-$head}" + +# Read-only fetches into refs this run alone uses: upstream's tags and default branch, and this +# fork's tags (its releases). +git fetch --quiet --no-tags "$upstream_url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" \ + > "$logs/fetch.log" 2>&1 \ + || fail "Could not fetch the tags and default branch of $UPSTREAM ($upstream_url):" '```' "$(tail -n 20 "$logs/fetch.log")" '```' +git fetch --quiet --no-tags "$ORIGIN" "+refs/tags/v*:refs/fork-tags/v*" > "$logs/fetch.log" 2>&1 \ + || fail "Could not fetch the tags of this fork ($ORIGIN):" '```' "$(tail -n 20 "$logs/fetch.log")" '```' + +upstream_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags | grep -E "$stable_re" | sort -rV || true)" +fork_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/fork-tags | grep -E "$fork_re" | sort -rV || true)" +target_tag="$(sed -n 1p <<< "$upstream_tags")" +[[ -n "$target_tag" ]] || fail "$UPSTREAM has no stable release tag (vX.Y.Z)." +up() { git rev-parse "refs/upstream-tags/$1^{commit}"; } + +# The newest fork release in the series' history names the upstream tag the series was built on. +# If upstream deleted or moved that tag, the base found below would be an older tag (or upstream's +# rewritten commits would count as the fork's own), so stop and name the tag instead. +last_release="" +for tag in $fork_tags; do + if git merge-base --is-ancestor "refs/fork-tags/$tag^{commit}" HEAD; then + last_release="$tag" + break + fi +done +if [[ -n "$last_release" ]]; then + built_on="${last_release%-fork.*}" + if ! git rev-parse --quiet --verify "refs/upstream-tags/$built_on^{commit}" > /dev/null; then + fail "$UPSTREAM no longer has the tag $built_on, which the series was built on (fork release $last_release is in its history)." \ + "Upstream deleted or renamed that release. Decide by hand which upstream release the series should sit on; the series was left as it is." + fi + if ! git merge-base --is-ancestor "$(up "$built_on")" HEAD; then + fail "$UPSTREAM moved the tag $built_on, which the series was built on (fork release $last_release is in its history), to a commit the series does not contain." \ + "Decide by hand whether to rebase onto the moved tag; the series was left as it is." + fi +fi + +# The series sits on the newest stable upstream tag it contains. +base_tag="" +for tag in $upstream_tags; do + if git merge-base --is-ancestor "$(up "$tag")" HEAD; then + base_tag="$tag" + base="$(up "$tag")" + break + fi +done +[[ -n "$base_tag" ]] || fail "The series ($head) contains no stable $UPSTREAM tag." + +# Every commit between that tag and the series head must be the fork's own. Upstream commits there +# mean the branch is not a patch series (for example it still equals upstream's default branch). +series_count="$(git rev-list --count "$base..HEAD")" +own_count="$(git rev-list --count "$base..HEAD" --not refs/upstream-head)" +if [[ "$series_count" != "$own_count" ]]; then + fail "The series holds $((series_count - own_count)) upstream commits after $base_tag." \ + "Reset it to $base_tag plus the fork's own commits. The series was left as it is." +fi + +# A rebase flattens merge commits, and a change made in a merge itself (a conflict resolved in the +# merge, an edited merge) would be lost without a trace. The series must be plain commits. +merges="$(git rev-list --merges "$base..HEAD")" +if [[ -n "$merges" ]]; then + fail "The series holds merge commits after $base_tag:" \ + "$(git log --no-walk --format='- %h %s' $merges)" "" \ + "A rebase would flatten them and drop any change made in the merge itself, so the series was left as it is." \ + "Rebuild it as plain commits: check that \`git show --remerge-diff \` prints no change for each merge (or keep that change as its own commit)," \ + "run \`git rebase --onto $base_tag $base_tag \` and force-push it. Merge fork pull requests only with squash or rebase." +fi + +# The schema check exits 1 both when it refuses and when node crashes; only a refusal prints this. +schema_refusal="Fork changes must not change the database schema" +schema_check() { # schema_check BASE WHAT: fails with the check's output, a refusal or a crash + local status=0 + node .github/scripts/fork-schema-check.cjs "$1" HEAD > "$logs/schema-check.log" 2>&1 || status=$? + ((status != 0)) || return 0 + if ((status == 1)) && grep -qF "$schema_refusal" "$logs/schema-check.log"; then + fail "$2 changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' + fi + fail "The schema check crashed (exit $status), so it did not say whether the series touches the database schema:" \ + '```' "$(tail -n 20 "$logs/schema-check.log")" '```' "The series was left as it is." +} +schema_check "$base" "The series" + +# The newest fork release of a tag: " ", or nothing. +latest_release() { + local last + last="$(grep -E "^${1//./\\.}-fork\.[0-9]+$" <<< "$fork_tags" | sort -V | tail -n1 || true)" + [[ -z "$last" ]] || echo "$last $(git rev-parse "refs/fork-tags/$last^{commit}")" +} + +# Workflow paths, read NUL-separated with core.quotepath=off, so a name with non-ASCII bytes, a +# quote or a backslash comes out as GitHub lists it. The lists are ','-joined, so a name with a ',' +# or a newline cannot be named and the plan refuses it. These run in the plan's own shell (no +# command substitution), so a refusal stops the plan. +workflow_paths() { # workflow_paths FILTER FROM TO: sets paths, the ','-joined files FROM..TO shows as FILTER + local path + paths="" + git -c core.quotepath=off diff -z --no-renames --name-only --diff-filter="$1" "$2" "$3" -- .github/workflows \ + > "$logs/paths" || fail "Could not list the workflow files that differ between ${2:0:12} and ${3:0:12}." + while IFS= read -r -d '' path; do + if [[ "$path" == *[,$'\n']* ]]; then + fail "The workflow file \`$path\` (between ${2:0:12} and ${3:0:12}) has a ',' or a newline in its name, so it cannot be named to the workflow sweep." \ + "The series was left as it is. Rename or delete it with a fork patch." + fi + paths+="${paths:+,}$path" + done < "$logs/paths" +} +own_workflows() { # own_workflows UPSTREAM_COMMIT TIP: sets own_list, the workflow files the series adds + workflow_paths A "$1" "$2" + own_list="$paths" +} +new_workflows() { # new_workflows FROM TO: sets new_list, the upstream workflow files added or changed + local path list=() + workflow_paths AM "$1" "$2" # a rename counts as new + IFS=, read -r -a list <<< "$paths" + new_list="" + for path in "${list[@]}"; do + [[ "$path" != *.yml && "$path" != *.yaml ]] || new_list+="${new_list:+,}$path" + done +} + +# The sweep (fork-workflows.sh) takes every .github/workflows/fork-*.y[a]ml as the fork's own, even +# one the series does not have yet, and never disables it. A file of that name that the upstream +# release has and the candidate keeps would stay enabled, so the plan refuses it. +fork_named() { # fork_named COMMIT: its .github/workflows/fork-*.y[a]ml files, one per line + git -c core.quotepath=off ls-tree -r -z --name-only "$1" -- .github/workflows \ + | tr '\0' '\n' | { grep -E '^\.github/workflows/fork-[^/]*\.ya?ml$' || true; } +} +refuse_upstream_fork_named() { # refuse_upstream_fork_named UPSTREAM_COMMIT TAG CANDIDATE + local named + named="$(comm -12 <(fork_named "$1" | sort) <(fork_named "$3" | sort))" + [[ -z "$named" ]] || fail "$UPSTREAM $2 has workflow files named like the fork's own (fork-*.yml), and the candidate keeps them:" \ + "$(sed 's/^/- /' <<< "$named")" "" \ + "The workflow sweep never disables a fork-* workflow, so they would run in the fork. The series was left as it is." \ + "Add a fork patch that deletes them (or rename the fork's rule in fork-workflows.sh and fork-follow.sh), then start the follow workflow again." +} + +# The release job runs the reusable workflows of this run's commit (RUN_SHA), not the candidate's. +# They differ after upstream changed them, and on a re-run (or a queued run) of a run whose commit +# is older than the series. Building then would mix two versions of the release steps. +if ! git cat-file -e "$run_sha^{commit}" 2> /dev/null; then + git fetch --quiet --no-tags "$ORIGIN" "$run_sha" > "$logs/fetch.log" 2>&1 \ + || fail "This run's commit $run_sha is not in the fork, so the plan cannot compare its release workflows with the candidate's." \ + "Start a new run (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`) instead of re-running this one." +fi +called() { # the release workflow and every local workflow it calls, at commit $1, one per line + local queue=(.github/workflows/fork-release.yml) seen="" file used + while ((${#queue[@]})); do + file="${queue[0]}" + queue=("${queue[@]:1}") + grep -qxF -- "$file" <<< "$seen" && continue + seen+="$file"$'\n' + while read -r used; do + [[ -z "$used" ]] || queue+=("$used") + done <<< "$({ git show "$1:$file" 2> /dev/null || true; } \ + | sed -n 's|^[[:space:]-]*uses:[[:space:]]*["'\'']\{0,1\}\./\(\.github/workflows/[^[:space:]@#"'\'']*\).*|\1|p')" + done + printf '%s' "$seen" +} +changed_release_workflows() { # lists the release workflow files that differ between RUN_SHA and $1 + local file + while read -r file; do + [[ -n "$file" ]] || continue + if [[ "$(git rev-parse --quiet --verify "$run_sha:$file" || true)" != "$(git rev-parse --quiet --verify "$1:$file" || true)" ]]; then + printf -- '- %s\n' "$file" + fi + done <<< "$(sort -u <<< "$(called "$run_sha")"$'\n'"$(called "$1")")" +} +stage_report() { # stage_report CANDIDATE TAG CHANGED + printf '%s\n' \ + "The release workflows differ between this run's commit (${run_sha:0:12}) and the candidate ${1:0:12} on $2:" \ + "${3%$'\n'}" "" \ + "A run uses the reusable workflows of its own commit, so this run would build the candidate with other release steps than its own; it built nothing." \ + "The candidate ${1:0:12} is on the branch fork-follow/candidate. Release it with its own workflow files:" \ + "\`gh workflow run fork-release.yml --repo $REPOSITORY --ref fork-follow/candidate -f ref=$1 -f publish=true\`" \ + "The next follow run then finds that release (and moves the series to it if it is not there yet)." > "$REPORT" +} + +if [[ "$target_tag" == "$base_tag" ]]; then + refuse_upstream_fork_named "$base" "$base_tag" "$head" + read -r released_tag released_commit <<< "$(latest_release "$base_tag")" || true + if [[ "${released_commit:-}" == "$head" ]]; then + action=none + else + action=release + released_tag="" + changed="$(changed_release_workflows "$head")" + if [[ -n "$changed" ]]; then + action=stage + stage_report "$head" "$base_tag" "$changed" + fi + fi + own_workflows "$base" HEAD + { + echo "action=$action" + echo "base_tag=$base_tag" + echo "base_commit=$base" + echo "candidate=$head" + echo "released_tag=${released_tag:-}" + echo "own_workflows=$own_list" + echo "new_workflows=" + } >> "$OUTPUT" + exit 0 +fi + +# A newer upstream release: replay the series onto it. Patches upstream now has become empty and +# are dropped. +target="$(up "$target_tag")" +before="$(git log --format=%s "$base..HEAD")" +if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com \ + rebase --empty=drop --onto "$target" "$base" > "$logs/rebase.log" 2>&1; then + stopped="$(git log -1 --format='%h %s' REBASE_HEAD 2> /dev/null || echo unknown)" + conflicts="$(git diff --name-only --diff-filter=U | sed 's/^/- /')" + git rebase --abort || true + fail "Rebasing the series from $base_tag onto $target_tag stopped at: $stopped" "" \ + "Conflicting files:" "${conflicts:-(none listed; see the run log)}" "" \ + "The series was left as it is. To follow by hand: \`git fetch https://github.com/$UPSTREAM.git tag $target_tag\`," \ + "\`git rebase --onto $target_tag $base_tag \`, resolve, run the tests, then push the branch with" \ + "\`--force-with-lease\`, and start the follow workflow once (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`):" \ + "it releases the series and disables the upstream workflows that push started." +fi +schema_check "$target" "After rebasing onto $target_tag, the series" +candidate="$(git rev-parse HEAD)" +refuse_upstream_fork_named "$target" "$target_tag" "$candidate" +dropped="$(comm -23 <(sort <<< "$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" + +# A published fork release of the target with exactly these files exists (an earlier run published +# it, then could not move the series): move the series to it rather than build it again. +read -r released_tag released_commit <<< "$(latest_release "$target_tag")" || true +action=rebase +if [[ -n "${released_commit:-}" ]] \ + && [[ "$(git rev-parse "$released_commit^{tree}")" == "$(git rev-parse "$candidate^{tree}")" ]]; then + action=advance + candidate="$released_commit" +else + released_tag="" + changed="$(changed_release_workflows "$candidate")" + if [[ -n "$changed" ]]; then + action=stage + stage_report "$candidate" "$target_tag" "$changed" + fi +fi +own_workflows "$target" "$candidate" +new_workflows "$base" "$target" +{ + echo "action=$action" + echo "base_tag=$target_tag" + echo "base_commit=$target" + echo "previous_tag=$base_tag" + echo "previous_head=$head" + echo "candidate=$candidate" + echo "released_tag=${released_tag:-}" + echo "dropped=$dropped" + echo "own_workflows=$own_list" + echo "new_workflows=$new_list" +} >> "$OUTPUT" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh new file mode 100755 index 0000000000000..eb5a15abecc2e --- /dev/null +++ b/.github/scripts/fork-follow.test.sh @@ -0,0 +1,757 @@ +#!/usr/bin/env bash +# Tests for fork-follow.yml and its scripts against local git repositories and a fake `gh`; no +# network. FORK_FOLLOW_DIR runs the tests against the scripts in another directory. +# bash .github/scripts/fork-follow.test.sh +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dir="$(realpath "${FORK_FOLLOW_DIR:-$here}")" +workflow="$(realpath "${FORK_FOLLOW_WORKFLOW:-$here/../workflows/fork-follow.yml}")" +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the scripts use. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ + SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ + FORK_PUSH_ENVIRONMENT + +# Fake gh, served from $root/api: +# gh api [-X M] PATH [--paginate] [--jq EXPR] gh issue list|comment|create|close ... +# A file $root/api/fail_ makes every call whose path contains fail. Run listings are +# filtered by their head_sha= and status= query like GitHub's (created= is ignored). +# fork_test_gh (fork-test-gh.sh) installs it first on PATH, with no token, a .invalid GH_HOST and +# FORK_TEST_MODE, so the scripts refuse to run if any other gh would answer. +. "$here/fork-test-gh.sh" +mkdir -p "$root/bin" "$root/api" +fork_test_gh "$root/bin" << 'GH' +set -euo pipefail +api="${FAKE_API:?the gh stub needs FAKE_API}" +echo "gh $*" >> "$api/calls" +if [[ "$1" == issue ]]; then + # Like GitHub: with Issues off, every issue command fails. + if [[ "$(cat "$api/has_issues")" != true ]]; then + echo "the 'me/t3code' repository has disabled issues" >&2 + exit 1 + fi + echo "issue $2" >> "$api/writes" + case "$2" in + list) cat "$api/issues" 2> /dev/null || true ;; + create) echo "https://github.com/me/t3code/issues/9" ;; + esac + exit 0 +fi +shift +method=GET path="" jq="" +while (( $# )); do + case "$1" in + -X) method="$2"; shift 2 ;; + --jq) jq="$2"; shift 2 ;; + --paginate) shift ;; + *) path="$1"; shift ;; + esac +done +for f in "$api"/fail_*; do + [[ -e "$f" ]] || continue + [[ "$path" != *"${f##*/fail_}"* ]] || { echo "HTTP 502: Bad Gateway ($path)" >&2; exit 1; } +done +case "$method $path" in + "GET repos/me/t3code") body="{\"has_issues\":$(cat "$api/has_issues"),\"parent\":{\"full_name\":\"up/t3code\"}}" ;; + "GET repos/me/t3code/actions/workflows") body="$(cat "$api/workflows.json")" ;; + "PUT repos/me/t3code/actions/workflows/"*/disable) echo "disable $path" >> "$api/writes"; exit 0 ;; + "GET repos/me/t3code/actions/runs?"*) + query="${path#*\?}" sha="" st="" + [[ "&$query" != *"&head_sha="* ]] || { sha="${query#*head_sha=}"; sha="${sha%%&*}"; } + [[ "&$query" != *"&status="* ]] || { st="${query#*status=}"; st="${st%%&*}"; } + body="$(jq --arg sha "$sha" --arg st "$st" \ + '.workflow_runs |= map(select(($sha == "" or .head_sha == $sha) and ($st == "" or .status == $st)))' "$api/runs.json")" ;; + "GET repos/me/t3code/contents/.github/workflows?ref="*) body="$(cat "$api/files.json")" ;; + "GET repos/me/t3code/environments/fork-push") + [[ -f "$api/environment.json" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + body="$(cat "$api/environment.json")" ;; + "GET repos/me/t3code/environments/fork-push/deployment-branch-policies"*) body="$(cat "$api/branch-policies.json")" ;; + "POST repos/me/t3code/actions/runs/"*/cancel) echo "cancel $path" >> "$api/writes"; exit 0 ;; + "GET repos/me/t3code/releases/tags/"*) + tag="${path##*/}" + [[ -f "$api/release-$tag" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + body="$(cat "$api/release-$tag")" ;; + *) echo "fake gh: no route for $method $path" >&2; exit 1 ;; +esac +if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi +GH +export FAKE_API="$root/api" + +failures=0 +pass() { echo "ok $1"; } +fail() { + echo "FAIL $1" + sed 's/^/ | /' "$root/out" 2> /dev/null || true + failures=$((failures + 1)) +} +check() { # check NAME CONDITION...: passes when the condition (a command) succeeds + local name="$1" + shift + if "$@"; then pass "$name"; else fail "$name"; fi +} +has() { grep -q -- "$1" "$2" 2> /dev/null; } +hasnt() { ! grep -q -- "$1" "$2" 2> /dev/null; } + +reset_api() { + rm -rf "$root/api" && mkdir -p "$root/api" + echo "${1:-false}" > "$root/api/has_issues" + echo '{"workflows":[]}' > "$root/api/workflows.json" + echo '{"workflow_runs":[]}' > "$root/api/runs.json" + echo '[]' > "$root/api/files.json" +} + +# --- repositories -------------------------------------------------------------------------------- +up_git() { (cd "$root/upwork" && "$@" > /dev/null 2>&1); } +up_push() { up_git git push -q --force https://github.com/up/t3code.git "$@"; } + +# Upstream: u1 (v0.1.8) - u2 (v0.1.9) - u3 (v0.1.10) - u4 (upstream main). Each adds a file, and +# upstream's release-desktop.yml is the same in all of them unless a test changes it. +new_upstream() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + mkdir -p .github/workflows + echo "on: workflow_call" > .github/workflows/release-desktop.yml + echo "on: push" > .github/workflows/ci.yml + echo base > shared.txt + git add -A && git commit -q -m u1 && git tag v0.1.8 + echo u2 > u2.txt && git add -A && git commit -q -m u2 && git tag -a -m v0.1.9 v0.1.9 + echo u3 > u3.txt && git add -A && git commit -q -m u3 && git tag v0.1.10 + echo u4 > u4.txt && git add -A && git commit -q -m u4 + git push -q https://github.com/up/t3code.git main --tags + ) +} + +# The fork's checkout in $root/work: upstream rev $1 plus the fork's own commit (its workflow +# files and scripts, and feature.txt), pushed as the fork's main. +new_fork() { + rm -rf "$root/work" + git clone -q https://github.com/up/t3code.git "$root/work" 2> /dev/null + ( + cd "$root/work" + git remote set-url origin https://github.com/me/t3code.git + git checkout -q -B main "$1" + mkdir -p .github/scripts .github/workflows + cp "$here/fork-schema-check.cjs" .github/scripts/ + printf 'jobs:\n desktop:\n uses: ./.github/workflows/release-desktop.yml\n' > .github/workflows/fork-release.yml + echo "on: workflow_dispatch" > .github/workflows/fork-follow.yml + echo feature > feature.txt + git add -A && git commit -q -m "fork: own change" + git push -q --force origin main 2> /dev/null + ) +} +work() { (cd "$root/work" && "$@" > /dev/null 2>&1); } +fork_tag() { work git tag -f "$1" "${2:-HEAD}" && work git push -q --force origin "refs/tags/$1"; } + +plan() { # runs the plan in $root/work; output in $root/plan.out, report in $root/report.md + rm -f "$root/report.md" "$root/plan.out" + (cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +} +out() { sed -n "s/^$1=//p" "$root/plan.out" 2> /dev/null | tail -n1; } +action_is() { [[ "$(out action)" == "$1" ]]; } +series() { git -C "$root/gh/me/t3code.git" rev-parse "refs/heads/${1:-main}" 2> /dev/null; } +work_head() { git -C "$root/work" rev-parse HEAD; } + +# --- plan: fork-follow.sh --------------------------------------------------------------------- +new_upstream +reset_api +new_fork v0.1.10 +plan +check "plan: same base, no fork release yet -> release" action_is release +fork_tag v0.1.10-fork.1 +plan +check "plan: the series head is the newest release -> none, naming it" \ + eval 'action_is none && [[ "$(out released_tag)" == v0.1.10-fork.1 ]]' +check "plan: own_workflows lists only the files the series adds" \ + eval '[[ "$(out own_workflows)" == .github/workflows/fork-follow.yml,.github/workflows/fork-release.yml ]]' + +new_upstream +new_fork v0.1.9 +before="$(work_head)" +plan +check "plan: a newer tag with a clean rebase -> rebase onto it" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.10 && "$(out previous_head)" == "$before" ]]' + +new_fork v0.1.9 +(cd "$root/work" && echo fork > u3.txt && git add -A && git commit -q -m "fork: edit u3" && git push -q origin main) > /dev/null 2>&1 +plan +check "plan: a conflict names the stopped patch and its file and aborts the rebase" \ + eval 'has "fork: edit u3" "$root/report.md" && has "u3.txt" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" ]]' +check "plan: the conflict advice says to start the follow workflow once after the manual push" \ + has "gh workflow run fork-follow.yml" "$root/report.md" + +new_fork main +plan +check "plan: upstream commits inside the series are refused" has "upstream commits after" "$root/report.md" + +# Major: merge commits. A fork PR merged with a merge commit that carries its own change. +new_fork v0.1.9 +( + cd "$root/work" + git checkout -q -b side HEAD~0 && echo side > side.txt && git add -A && git commit -q -m "fork: side" + git checkout -q main && echo other > other.txt && git add -A && git commit -q -m "fork: other" + git merge -q --no-ff --no-commit side && echo evil > evil.txt && git add -A && git commit -q -m "Merge side" + git push -q origin main +) > /dev/null 2>&1 +before="$(work_head)" +plan +check "plan: merge commits in the series are refused and named, the series left as it is" \ + eval '! action_is rebase && has "merge commits" "$root/report.md" && has "Merge side" "$root/report.md" && [[ "$(work_head)" == "$before" ]]' + +# Minor: a failed fetch leaves a reason that names it. +new_fork v0.1.10 +(cd "$root/work" && UPSTREAM_URL="$root/nowhere.git" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: a failed upstream fetch writes a reason naming it" has "Could not fetch" "$root/report.md" + +# Minor: any other failing command still writes a reason (fake git fails `rev-list --merges`). +cat > "$root/bin/git-fail-merges" << 'G' +#!/usr/bin/env bash +for a in "$@"; do [[ "$a" != --merges ]] || { echo "fatal: simulated" >&2; exit 128; }; done +exec "$REAL_GIT" "$@" +G +chmod +x "$root/bin/git-fail-merges" +mkdir -p "$root/failgit" && ln -sf "$root/bin/git-fail-merges" "$root/failgit/git" +(cd "$root/work" && REAL_GIT="$(command -v git)" PATH="$root/failgit:$PATH" REPORT="$root/report.md" \ + GITHUB_OUTPUT="$root/plan.out" bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: an unexpected failure writes a reason naming the line" has "fork-follow.sh line" "$root/report.md" + +# Minor: upstream deletes or moves the tag the released series sits on. +new_upstream +new_fork v0.1.10 +fork_tag v0.1.10-fork.1 +git -C "$root/gh/up/t3code.git" tag -d v0.1.10 > /dev/null +plan +check "plan: upstream deleted the tag the series was built on -> named, not 'reset to v0.1.9'" \ + eval 'has "no longer has the tag v0.1.10" "$root/report.md" && hasnt "Reset it to v0.1.9" "$root/report.md"' +new_upstream +new_fork v0.1.10 +fork_tag v0.1.10-fork.1 +(cd "$root/upwork" && git checkout -q -b moved v0.1.9 && echo again > u3.txt && git add -A && git commit -q -m u3b \ + && git tag -f v0.1.10 && git push -q --force https://github.com/up/t3code.git refs/tags/v0.1.10) > /dev/null 2>&1 +plan +check "plan: upstream moved the tag the series was built on -> named" has "moved the tag v0.1.10" "$root/report.md" + +# State: empty or tagless history. No stable tag upstream, or none inside the series. +new_upstream +new_fork v0.1.10 +for t in v0.1.8 v0.1.9 v0.1.10; do git -C "$root/gh/up/t3code.git" tag -d "$t" > /dev/null; done +work git tag -d v0.1.8 v0.1.9 v0.1.10 +plan +check "plan: upstream has no stable tag -> refused with a reason, nothing planned" \ + eval 'has "no stable release tag" "$root/report.md" && [[ -z "$(out action)" ]]' +new_upstream +new_fork v0.1.8 +git -C "$root/gh/up/t3code.git" tag -d v0.1.8 > /dev/null +work git tag -d v0.1.8 +plan +check "plan: the series contains no stable upstream tag -> refused with a reason" \ + eval 'has "contains no stable" "$root/report.md" && [[ -z "$(out action)" ]]' + +# Major: a release was published, then the series branch did not move. The next plan moves the +# branch to that release instead of building and publishing another one. +new_upstream +new_fork v0.1.9 +plan +released="$(out candidate)" +work git push -q origin "$released:refs/heads/fork-follow/candidate" +fork_tag v0.1.10-fork.1 "$released" +work git checkout -q main && work git reset -q --hard "$(series)" +plan +check "plan: a published release of the rebased files exists -> advance to it, build nothing" \ + eval 'action_is advance && [[ "$(out candidate)" == "$released" && "$(out released_tag)" == v0.1.10-fork.1 ]]' +work git reset -q --hard "$(series)" +(cd "$root/work" && echo more > more.txt && git add -A && git commit -q -m "fork: more" && git push -q origin main) > /dev/null 2>&1 +plan +check "plan: a release of different files does not count -> rebase" action_is rebase + +# Round 3: upstream workflow files new on the target release are named, so the branch move can +# refuse while GitHub does not list one. A rename counts as new; a same-base plan names none. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: push" > .github/workflows/nightly.yml \ + && git mv .github/workflows/ci.yml .github/workflows/ci-v2.yml && git add -A && git commit -q -m u5 \ + && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: a rebase names the upstream workflow files new on the target release (a rename too)" \ + eval 'action_is rebase && [[ "$(out new_workflows)" == .github/workflows/ci-v2.yml,.github/workflows/nightly.yml ]]' +new_fork v0.1.11 +plan +check "plan: same base -> no new upstream workflow files" \ + eval 'action_is release && grep -qx "new_workflows=" "$root/plan.out"' + +# Round 4: the sweep never disables a fork-*.yml, so an upstream release that has one is refused, +# on the rebase path and on the same base, unless a fork patch deletes it. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: push" > .github/workflows/fork-sneaky.yml \ + && git add -A && git commit -q -m u5 && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: rebasing onto a release with an upstream fork-*.yml is refused and names it" \ + eval '[[ -z "$(out action)" ]] && has "named like the fork.s own" "$root/report.md" && has "fork-sneaky.yml" "$root/report.md"' +new_fork v0.1.11 +plan +check "plan: same base with an upstream fork-*.yml is refused too" \ + eval '[[ -z "$(out action)" ]] && has "fork-sneaky.yml" "$root/report.md"' +(cd "$root/work" && git rm -q .github/workflows/fork-sneaky.yml && git commit -q -m "fork: drop upstream fork-sneaky" \ + && git push -q origin main) > /dev/null 2>&1 +plan +check "plan: a fork patch that deletes the upstream fork-*.yml lets the plan go on" action_is release + +# Round 4: a changed upstream workflow counts like a new one (an unlisted file that now runs on push +# must hold the move too), and a non-ASCII name comes out as GitHub lists it, not quoted by git. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: [push] # changed" > .github/workflows/ci.yml \ + && echo "on: push" > .github/workflows/dé.yml && git add -A && git commit -q -m u5 \ + && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: a changed upstream workflow and a non-ASCII new one are both named, unquoted" \ + eval 'action_is rebase && [[ "$(out new_workflows)" == ".github/workflows/ci.yml,.github/workflows/dé.yml" ]]' +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: push" > ".github/workflows/a,b.yml" && git add -A \ + && git commit -q -m u5 && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: a new upstream workflow with ',' in its name (cannot be listed) is refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "a,b.yml" "$root/report.md" && has "cannot be named" "$root/report.md" && hasnt "plan stopped at" "$root/report.md"' + +# Minor: upstream changed a reusable workflow the release calls; the run's own copy is older. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: workflow_call # v2" > .github/workflows/release-desktop.yml \ + && git add -A && git commit -q -m u5 && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +(cd "$root/work" && RUN_SHA="$(git rev-parse HEAD)" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: changed release workflows -> stage, with the command that releases the candidate" \ + eval 'action_is stage && has "release-desktop.yml" "$root/report.md" && has "gh workflow run fork-release.yml" "$root/report.md"' + +# Round 2: the same comparison on the release path. A re-run (or queued run) of an older run: its +# commit is the series on v0.1.10, while the series already sits on v0.1.11, whose +# release-desktop.yml differs. Releasing would build v0.1.11 with v0.1.10's release steps. +# The stage plan above left the rebased series (on v0.1.11) checked out; the fork's main is still +# the series on v0.1.10, the older run's commit. Make the rebased series the fork's main. +old_run="$(series)" +work git push -q --force origin HEAD:main +(cd "$root/work" && RUN_SHA="$old_run" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: release path, the run's commit has other release workflows -> stage, not release" \ + eval 'action_is stage && [[ "$(out base_tag)" == v0.1.11 && "$(out candidate)" == "$(work_head)" ]] && has "release-desktop.yml" "$root/report.md"' +(cd "$root/work" && RUN_SHA="$(git rev-parse HEAD)" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: release path, the run's own commit -> release" action_is release +rm -f "$root/plan.out" +(cd "$root/work" && RUN_SHA=1234567890123456789012345678901234567890 REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: the run's commit is not in the fork -> refused with a reason, nothing planned" \ + eval 'has "is not in the fork" "$root/report.md" && [[ -z "$(out action)" ]]' + +# Nit: a release workflow that names a file missing at a commit makes `git show` fail inside a +# command substitution; that must not leave a stray "plan stopped" reason while the plan goes on. +new_upstream +new_fork v0.1.9 +(cd "$root/work" && printf ' uses: ./.github/workflows/missing.yml\n' >> .github/workflows/fork-release.yml \ + && git add -A && git commit -q --amend --no-edit && git push -q --force origin main) > /dev/null 2>&1 +plan +check "plan: a release workflow naming a missing file leaves no stray reason" \ + eval 'action_is rebase && [[ ! -e "$root/report.md" ]]' + +# Nit: the schema check crashing is not reported as a schema change; a refusal still is. +new_upstream +new_fork v0.1.10 +mkdir -p "$root/fakenode" +printf '#!/bin/sh\necho "Error: Cannot find module x (MODULE_NOT_FOUND)" >&2\nexit 1\n' > "$root/fakenode/node" +chmod +x "$root/fakenode/node" +(cd "$root/work" && PATH="$root/fakenode:$PATH" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +rm -f "$root/plan.out" +check "plan: a crashing schema check is reported as a crash, not a schema change" \ + eval 'has "schema check crashed (exit 1)" "$root/report.md" && hasnt "changes the database schema" "$root/report.md" && [[ -z "$(out action)" ]]' +printf '#!/bin/sh\necho "Fork changes must not change the database schema (no down-migrations exist):" >&2\necho "- a.sql" >&2\nexit 1\n' > "$root/fakenode/node" +(cd "$root/work" && PATH="$root/fakenode:$PATH" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: a schema check refusal is reported as a schema change" \ + eval 'has "The series changes the database schema" "$root/report.md" && has "a.sql" "$root/report.md"' + +# --- pushes: fork-follow-push.sh ------------------------------------------------------------- +new_upstream +new_fork v0.1.10 +push() { (cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/push.out" PUSH_URL="${PUSH_URL:-$root/gh/me/t3code.git}" \ + BASE_TAG=v0.1.10 bash "$dir/fork-follow-push.sh" "$@") > "$root/out" 2>&1; } +rm -f "$root/report.md" +check "push: a missing FORK_PUSH_TOKEN fails and says so" eval '! push check && has "FORK_PUSH_TOKEN is missing" "$root/report.md"' +rm -f "$root/report.md" +check "push: a missing FORK_PUSH_TOKEN names the Environment that should hold it" \ + eval '! FORK_PUSH_ENVIRONMENT=fork-push push check && has "missing in the Environment fork-push" "$root/report.md" && has "deployment branches to main" "$root/report.md"' +export FORK_PUSH_TOKEN=test-token +check "push: a token GitHub refuses (expired) fails and says so" \ + eval '! PUSH_URL="$root/gh/me/gone.git" push check && has "refused FORK_PUSH_TOKEN" "$root/report.md"' + +check "push: a working token passes the check" push check + +prev="$(series)" +work git commit -q --allow-empty -m "fork: next" +next="$(work_head)" +# Minor: a refused candidate push (say, a token without Workflows write) leaves a reason. +hook="$root/gh/me/t3code.git/hooks/pre-receive" +printf '#!/bin/sh\necho "refusing to allow a token without workflows permission" >&2\nexit 1\n' > "$hook" && chmod +x "$hook" +rm -f "$root/report.md" +check "push: a refused candidate push fails with git's reason" \ + eval '! push candidate "$next" && has "Could not push the candidate" "$root/report.md" && has "workflows permission" "$root/report.md"' +rm -f "$hook" +push candidate "$next" +check "push: the candidate goes to fork-follow/candidate" eval '[[ "$(series fork-follow/candidate)" == "$next" ]] && has candidate_pushed=true "$root/push.out"' +push advance "$prev" "$next" +check "push: advance moves the series and keeps the old one as a tag" \ + eval '[[ "$(series)" == "$next" && "$(git -C "$root/gh/me/t3code.git" rev-parse "refs/tags/fork-follow/pre-v0.1.10-${prev:0:12}")" == "$prev" ]]' +push advance "$prev" "$next" +check "push: advance again after it moved (re-run) succeeds without a push" has moved=already "$root/push.out" +work git commit -q --allow-empty -m "fork: merged meanwhile" +work git push -q origin HEAD:main +moved="$(series)" +rm -f "$root/report.md" +push advance "$next" "$prev" +check "push: advance refuses when the branch moved since the plan, and leaves it" \ + eval '[[ "$(series)" == "$moved" ]] && has "moved since the plan" "$root/report.md"' +push cleanup +check "push: cleanup deletes the candidate branch" eval '! series fork-follow/candidate > /dev/null' +unset FORK_PUSH_TOKEN + +# --- guard: fork-follow-guard.sh ----------------------------------------------------------------- +guard() { REPORT="$root/report.md" FORK_PUSH_ENVIRONMENT=fork-push bash "$dir/fork-follow-guard.sh" > "$root/out" 2>&1; } +good_env() { + reset_api + echo '{"name":"fork-push","deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}' > "$root/api/environment.json" + echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main","type":"branch"}]}' > "$root/api/branch-policies.json" +} +good_env +rm -f "$root/report.md" +check "guard: a repository or organisation secret FORK_PUSH_TOKEN -> refused, says to delete it" \ + eval '! REPO_SECRET_PRESENT=true guard && has "repository or organisation secret named FORK_PUSH_TOKEN exists" "$root/report.md" && has "Repository secrets" "$root/report.md"' +check "guard: the secret check's answer is missing -> refused" \ + eval '! REPO_SECRET_PRESENT= guard && has "could not tell" "$root/report.md"' +export REPO_SECRET_PRESENT=false +check "guard: only the Environment holds it and only main may deploy -> passes" guard +rm -f "$root/api/environment.json" +check "guard: the Environment does not exist -> refused, says how to create it" \ + eval '! guard && has "does not exist" "$root/report.md" && has "Selected branches and tags" "$root/report.md" && has "branch main" "$root/report.md"' +echo '{"name":"fork-push","deployment_branch_policy":null}' > "$root/api/environment.json" +check "guard: any branch may deploy (no rule, as GitHub auto-creates it) -> refused" \ + eval '! guard && has "Any branch may deploy" "$root/report.md" && has "Selected branches and tags" "$root/report.md"' +echo '{"name":"fork-push","deployment_branch_policy":{"protected_branches":true,"custom_branch_policies":false}}' > "$root/api/environment.json" +check "guard: protected branches only (not provably main alone) -> refused" \ + eval '! guard && has "Protected branches only" "$root/report.md"' +good_env +echo '{"total_count":2,"branch_policies":[{"id":1,"name":"main","type":"branch"},{"id":2,"name":"v*","type":"tag"}]}' > "$root/api/branch-policies.json" +check "guard: an extra tag rule -> refused, the rules listed" \ + eval '! guard && has "tag \`v\*\`" "$root/report.md" && has "exactly one rule" "$root/report.md"' +echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main*","type":"branch"}]}' > "$root/api/branch-policies.json" +check "guard: a pattern rule (main*) -> refused" eval '! guard && has "main\*" "$root/report.md"' +echo '{"total_count":0,"branch_policies":[]}' > "$root/api/branch-policies.json" +check "guard: no rule at all -> refused" eval '! guard && has "(none)" "$root/report.md"' +# Round 3: a series branch that is itself a pattern, with the matching rule, is still refused. +echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main*","type":"branch"}]}' > "$root/api/branch-policies.json" +check "guard: a series branch that is a pattern (main*), even with the rule 'branch main*' -> refused" \ + eval '! SERIES_BRANCH="main*" guard && has "must let only the branch main\*" "$root/report.md"' +good_env +check "guard: --environment-only (before the branch move) passes without the secret answer" \ + eval 'REPO_SECRET_PRESENT= REPORT="$root/report.md" FORK_PUSH_ENVIRONMENT=fork-push bash "$dir/fork-follow-guard.sh" --environment-only > "$root/out" 2>&1' +echo '{"name":"fork-push","deployment_branch_policy":null}' > "$root/api/environment.json" +check "guard: --environment-only still refuses an Environment any branch may deploy to" \ + eval '! REPORT="$root/report.md" FORK_PUSH_ENVIRONMENT=fork-push bash "$dir/fork-follow-guard.sh" --environment-only > "$root/out" 2>&1 && has "Any branch may deploy" "$root/report.md"' +good_env +touch "$root/api/fail_environments" +check "guard: the Environment cannot be read -> refused with gh's reason" \ + eval '! guard && has "Could not read the Environment" "$root/report.md" && has "502" "$root/report.md"' +unset REPO_SECRET_PRESENT + +# --- workflows: fork-workflows.sh ------------------------------------------------------------ +sweep() { rm -f "$root/sweep.out"; REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh" > "$root/out" 2>&1; } +reset_api +cat > "$root/api/workflows.json" << 'J' +{"workflows":[ + {"id":1,"state":"active","path":".github/workflows/fork-follow.yml"}, + {"id":2,"state":"active","path":".github/workflows/web-preview.yml"}, + {"id":3,"state":"disabled_manually","path":".github/workflows/ci.yml"}, + {"id":4,"state":"active","path":"dynamic/pages/pages-build-deployment"}]} +J +cat > "$root/api/runs.json" << 'J' +{"workflow_runs":[ + {"id":11,"status":"queued","head_sha":"abc","event":"push","path":".github/workflows/web-preview.yml"}, + {"id":12,"status":"in_progress","head_sha":"abc","event":"push","path":".github/workflows/fork-tests.yml"}, + {"id":13,"status":"completed","conclusion":"success","head_sha":"abc","event":"push","path":".github/workflows/relay-preview.yml"}, + {"id":14,"status":"in_progress","head_sha":"def","event":"pull_request_target","path":".github/workflows/relay-preview-cleanup.yml@refs/heads/main"}, + {"id":15,"status":"completed","conclusion":"cancelled","head_sha":"abc","event":"push","path":".github/workflows/ci.yml"}, + {"id":16,"status":"completed","conclusion":"success","head_sha":"def","event":"schedule","path":".github/workflows/fork-tests.yml"}]} +J +cat > "$root/api/files.json" << 'J' +[{"type":"file","path":".github/workflows/fork-follow.yml"}, + {"type":"file","path":".github/workflows/web-preview.yml"}, + {"type":"file","path":".github/workflows/ci.yml"}, + {"type":"file","path":".github/workflows/release.yml"}, + {"type":"file","path":".github/workflows/README.md"}, + {"type":"dir","path":".github/workflows/scripts"}] +J +OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc SINCE=2026-11-12T06:23:00Z sweep +check "workflows: an active upstream workflow is disabled and named; own, disabled and dynamic ones are left" \ + eval 'has "workflows/2/disable" "$root/api/writes" && [[ $(grep -c disable "$root/api/writes") == 1 ]] && has "disabled=.github/workflows/web-preview.yml" "$root/sweep.out"' +check "workflows: unfinished runs of upstream workflows are cancelled, own and finished ones are not" \ + eval 'has "runs/11/cancel" "$root/api/writes" && hasnt "runs/12/cancel" "$root/api/writes" && hasnt "runs/13/cancel" "$root/api/writes"' +check "workflows: an upstream run from another event and commit (pull_request_target) is cancelled too" \ + eval 'has "runs/14/cancel" "$root/api/writes" && [[ $(grep -c cancel "$root/api/writes") == 2 ]]' +check "workflows: upstream runs that finished before the sweep are named with event and conclusion; own and cancelled ones are not" \ + eval '[[ "$(sed -n "s/^finished=//p" "$root/sweep.out")" == "13 (.github/workflows/relay-preview.yml: success after push)" ]]' +check "workflows: an unlisted upstream file from an earlier release is named apart, with no warning" \ + eval '[[ -z "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" && "$(sed -n "s/^unregistered_old=//p" "$root/sweep.out")" == ".github/workflows/release.yml" ]] && hasnt "::warning::.*does not list yet" "$root/out"' +rm -f "$root/api/writes" +OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/release.yml sweep +check "workflows: an unlisted upstream file new on this release is named with a warning, not called disabled" \ + eval '[[ "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" == ".github/workflows/release.yml" && -z "$(sed -n "s/^unregistered_old=//p" "$root/sweep.out")" ]] && has "::warning::.*new or changed on this release.*does not list yet" "$root/out"' +rm -f "$root/api/writes" "$root/report.md" +check "workflows: before the move, a new unlisted upstream file refuses it, after disabling the listed ones" \ + eval '! OWN_WORKFLOWS=.github/workflows/fork-follow.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/release.yml REQUIRE_NEW_OFF=true sweep && has "main was not moved" "$root/report.md" && has "release.yml" "$root/report.md" && has "A person decides" "$root/report.md" && has "workflows/2/disable" "$root/api/writes"' +rm -f "$root/report.md" +check "workflows: before the move, a new upstream file GitHub lists (now disabled) and older unlisted ones do not refuse it" \ + eval 'OWN_WORKFLOWS=.github/workflows/fork-follow.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/web-preview.yml REQUIRE_NEW_OFF=true sweep && [[ ! -e "$root/report.md" ]]' +# Round 4: a fork-*.yml that main does not have yet (a fork pull request adds it; GitHub lists it +# from the PR's branch) is the fork's own: never disabled, its runs never cancelled. A name that +# only contains "fork" is upstream's. +reset_api +cat > "$root/api/workflows.json" << 'J' +{"workflows":[ + {"id":1,"state":"active","path":".github/workflows/fork-follow.yml"}, + {"id":5,"state":"active","path":".github/workflows/fork-tests.yml"}, + {"id":6,"state":"active","path":".github/workflows/fork-new.yaml"}, + {"id":7,"state":"active","path":".github/workflows/mobile-eas-preview-fork.yml"}]} +J +cat > "$root/api/runs.json" << 'J' +{"workflow_runs":[ + {"id":21,"status":"in_progress","head_sha":"pr","event":"pull_request","path":".github/workflows/fork-tests.yml"}, + {"id":22,"status":"queued","head_sha":"pr","event":"pull_request","path":".github/workflows/fork-new.yaml@refs/pull/9/merge"}, + {"id":23,"status":"queued","head_sha":"abc","event":"push","path":".github/workflows/mobile-eas-preview-fork.yml"}]} +J +echo '[{"type":"file","path":".github/workflows/fork-follow.yml"},{"type":"file","path":".github/workflows/mobile-eas-preview-fork.yml"}]' > "$root/api/files.json" +OWN_WORKFLOWS=.github/workflows/fork-follow.yml sweep +check "workflows: a fork-*.yml/.yaml absent from main (a fork PR adds it) is never disabled, its runs never cancelled" \ + eval 'hasnt "workflows/5/disable" "$root/api/writes" && hasnt "workflows/6/disable" "$root/api/writes" && hasnt "runs/21/cancel" "$root/api/writes" && hasnt "runs/22/cancel" "$root/api/writes"' +check "workflows: an upstream name that only contains 'fork' is still disabled and its runs cancelled" \ + eval 'has "workflows/7/disable" "$root/api/writes" && has "runs/23/cancel" "$root/api/writes" && [[ $(grep -c . "$root/api/writes") == 2 ]]' + +reset_api +OWN_WORKFLOWS="" sweep +check "workflows: an empty own list disables nothing (refusing to guess)" eval '[[ ! -e "$root/api/writes" ]] && has "refusing to guess" "$root/report.md"' +touch "$root/api/fail_workflows" +OWN_WORKFLOWS=.github/workflows/fork-follow.yml sweep +check "workflows: a failed listing fails with a reason" has "Could not list the workflows" "$root/report.md" + +# --- report: fork-follow-report.sh ----------------------------------------------------------- +new_upstream +new_fork v0.1.10 +report() { # report KEY=VALUE...: the summary in $root/summary.md, status in $root/status + rm -f "$root/summary.md" + (cd "$root/work" && env GITHUB_STEP_SUMMARY="$root/summary.md" RUN_URL=run REPORTS="$root/reports" ORIGIN="$root/gh/me/t3code.git" "$@" \ + bash "$dir/fork-follow-report.sh") > "$root/out" 2>&1 + echo $? > "$root/status" +} +failed_run() { [[ "$(cat "$root/status")" != 0 ]]; } +rm -rf "$root/reports" && mkdir -p "$root/reports" +echo "Rebasing the series from v0.1.9 onto v0.1.10 stopped at: abc fork: x" > "$root/reports/plan.md" +reset_api false +report ACTION= PLAN_RESULT=failure +check "report: Issues disabled -> the reason is in the summary, no issue call, the run fails" \ + eval 'failed_run && has "stopped at" "$root/summary.md" && has "Issues are disabled" "$root/summary.md" && hasnt "issue" "$root/api/writes"' +check "report: Issues enabled -> one issue is opened on failure" \ + eval 'reset_api true; report ACTION= PLAN_RESULT=failure; failed_run && has "issue create" "$root/api/writes"' + +# Major: release published, then the series branch did not move. +rm -rf "$root/reports" && mkdir -p "$root/reports" +echo "main moved since the plan" > "$root/reports/advance.md" +prev="$(series)" +work git commit -q --allow-empty -m "fork: rebased" +cand="$(work_head)" +fork_tag v0.1.10-fork.2 "$cand" +reset_api false +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=success ADVANCE_RESULT=failure \ + BASE_TAG=v0.1.10 PREVIOUS_TAG=v0.1.9 PREVIOUS_HEAD="$prev" CANDIDATE="$cand" +check "report: published but not moved -> says both, never 'nothing was published'" \ + eval 'failed_run && has "v0.1.10-fork.2 \*\*published\*\*" "$root/summary.md" && has "not moved" "$root/summary.md" && hasnt -i "nothing was published" "$root/summary.md"' +rm -rf "$root/reports" && mkdir -p "$root/reports" +report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=failure ADVANCE_RESULT=skipped \ + BASE_TAG=v0.1.11 PREVIOUS_HEAD="$prev" CANDIDATE="$(git -C "$root/work" rev-parse HEAD~1)" +check "report: a failed release with no tag -> 'none published', read from the repository" \ + eval 'failed_run && has "none published" "$root/summary.md" && has "A job failed without writing a reason" "$root/summary.md"' + +# Minor: an issue opened by a failure closes once a run finds nothing to do (fixed by hand). +reset_api true +echo 7 > "$root/api/issues" +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +work git push -q --force origin "$cand:refs/heads/main" +report ACTION=none PLAN_RESULT=success RELEASE_RESULT=skipped ADVANCE_RESULT=skipped BASE_TAG=v0.1.10 \ + CANDIDATE="$cand" RELEASED_TAG=v0.1.10-fork.2 +check "report: action none closes an open follow issue and the run passes" \ + eval '! failed_run && has "issue close" "$root/api/writes"' +reset_api false +echo "The release workflows differ" > "$root/reports/plan.md" +report ACTION=stage PLAN_RESULT=failure CANDIDATE="$cand" +check "report: stage fails the run with the reason" eval 'failed_run && has "release workflows differ" "$root/summary.md"' +reset_api false +report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" DISABLED=",.github/workflows/web-preview.yml,,.github/workflows/web-preview.yml" SWEEP_RESULT=success +check "report: names each disabled upstream workflow once" \ + eval '[[ $(grep -o "web-preview.yml" "$root/summary.md" | wc -l) == 1 ]]' +reset_api false +report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" SWEEP_RESULT=success \ + FINISHED="13 (.github/workflows/relay-preview.yml: success after push)" UNREGISTERED=".github/workflows/release.yml" +check "report: names upstream runs that finished first and files GitHub does not list yet" \ + eval 'has "finished before a sweep could stop them" "$root/summary.md" && has "relay-preview.yml: success after push" "$root/summary.md" && has "does not list yet" "$root/summary.md" && has "release.yml" "$root/summary.md"' +reset_api false +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" SWEEP_RESULT=success UNREGISTERED_OLD=".github/workflows/publish-aur.yml" +check "report: unlisted upstream files from earlier releases go in a collapsed note, not a bold row, and pass" \ + eval '! failed_run && has "
" "$root/summary.md" && has "publish-aur.yml" "$root/summary.md" && hasnt "\*\*.*publish-aur" "$root/summary.md"' +# Round 3: "Re-run failed jobs" keeps the guard's earlier result; the token jobs skip themselves. +rm -rf "$root/reports" && mkdir -p "$root/reports" +reset_api false +report ACTION= GUARD_RESULT=success GUARD_ATTEMPT=1 RUN_ATTEMPT=2 PLAN_RESULT=skipped +check "report: the guard passed in an earlier attempt, the plan skipped -> fails, says to re-run all jobs" \ + eval 'failed_run && has "did not run in this attempt" "$root/summary.md" && has "Re-run all jobs" "$root/summary.md"' +report ACTION=rebase GUARD_RESULT=success GUARD_ATTEMPT=1 RUN_ATTEMPT=2 PLAN_RESULT=success RELEASE_RESULT=success \ + ADVANCE_RESULT=skipped BASE_TAG=v0.1.10 PREVIOUS_HEAD="$prev" CANDIDATE="$cand" +check "report: released, but the move skipped for an earlier attempt's guard -> fails and says why" \ + eval 'failed_run && has "guard passed in attempt 1" "$root/summary.md"' +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +report ACTION=none GUARD_RESULT=success GUARD_ATTEMPT=1 RUN_ATTEMPT=2 PLAN_RESULT=success CANDIDATE="$cand" RELEASED_TAG=v0.1.10-fork.2 +check "report: a re-run of the report alone after a good run does not cry wolf" eval '! failed_run' +rm -rf "$root/reports" && mkdir -p "$root/reports" +echo "A repository or organisation secret named FORK_PUSH_TOKEN exists" > "$root/reports/guard.md" +report ACTION= GUARD_RESULT=failure PLAN_RESULT=skipped +check "report: the guard refused -> the run fails with its reason, and says no job got the token" \ + eval 'failed_run && has "secret named FORK_PUSH_TOKEN exists" "$root/summary.md" && has "guard refused" "$root/summary.md"' + +# --- gh: no script reaches GitHub outside a run (fork-gh-guard.sh) ------------------------------ +# A gh that is not the stub (a test file run without it, an older test, a person running a script +# by hand) answers like GitHub and records every call. Each script that calls gh must refuse before +# it calls any gh: neither this one nor the stub. +mkdir -p "$root/decoy" "$root/nomarker" "$root/reports" +cat > "$root/decoy/gh" << 'D' +#!/usr/bin/env bash +echo "gh $*" >> "$DECOY_CALLS" +echo '{"workflows":[{"id":9,"state":"active","path":".github/workflows/fork-tests.yml"}]}' +D +chmod +x "$root/decoy/gh" +grep -vxF "$FORK_GH_STUB_MARKER" "$root/bin/gh" > "$root/nomarker/gh" && chmod +x "$root/nomarker/gh" +gh_scripts=(fork-workflows.sh fork-follow-guard.sh fork-follow-report.sh) +refuses() { # refuses ENV...: every gh script, run with ENV, fails, says why, and calls no gh + local s + for s in "${gh_scripts[@]}"; do + rm -f "$root/decoy.calls" "$root/api/calls" "$root/report.md" + if (cd "$root/work" && env DECOY_CALLS="$root/decoy.calls" OWN_WORKFLOWS=.github/workflows/fork-follow.yml \ + REPORT="$root/report.md" REPO_SECRET_PRESENT=false FORK_PUSH_ENVIRONMENT=fork-push REPORTS="$root/reports" \ + ACTION=none PLAN_RESULT=success "$@" bash "$dir/$s") > "$root/out" 2>&1; then + echo "$s passed" >> "$root/out" + return 1 + fi + [[ ! -e "$root/decoy.calls" && ! -e "$root/api/calls" ]] || { echo "$s called gh" >> "$root/out"; return 1; } + grep -q "Refusing to run $s" "$root/out" || { echo "$s gave no refusal" >> "$root/out"; return 1; } + done +} +reset_api +check "gh: a test run without the stub first on PATH fails closed and never calls gh" \ + refuses PATH="$root/decoy:$PATH" +check "gh: run by hand (no test mode, not in a GitHub Actions run) refuses before any gh call" \ + refuses FORK_TEST_MODE= PATH="$root/decoy:$PATH" +check "gh: test mode with a GitHub token set refuses, even with the stub" refuses GH_TOKEN=x +check "gh: test mode with GITHUB_TOKEN set refuses, even with the stub" refuses GITHUB_TOKEN=x +check "gh: test mode with GH_HOST github.com refuses" refuses GH_HOST=github.com +check "gh: a gh named as the stub but without its marker line refuses" \ + refuses PATH="$root/nomarker:$PATH" FORK_GH_STUB="$root/nomarker/gh" +rm -f "$root/api/calls" +check "gh: in a GitHub Actions run (not a test) the scripts use gh" \ + eval '(cd "$root/work" && env -u FORK_TEST_MODE GITHUB_ACTIONS=true OWN_WORKFLOWS=.github/workflows/fork-follow.yml \ + REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh") > "$root/out" 2>&1 && has "actions/workflows" "$root/api/calls"' +# A script that changes PATH after it started still cannot reach another gh: every call checks. +check "gh: each gh call checks again (PATH changed after the start) and refuses" \ + eval '! (. "$here/fork-gh-guard.sh"; PATH="$root/decoy:$PATH"; DECOY_CALLS="$root/decoy.calls" gh api user) > "$root/out" 2>&1 && [[ ! -e "$root/decoy.calls" ]] && has "Refusing to call gh" "$root/out"' + +# --- the workflow file --------------------------------------------------------------------------- +desktop="$here/../workflows/release-desktop.yml" +declared="$(sed -n '/^ workflow_call:/,/^ inputs:/p' "$desktop" | sed -n 's/^ \([A-Z_]*\):$/\1/p' | sort)" +passed="$(sed -n '/^ release:/,/^ [a-z_]*:$/p' "$workflow" | sed -n 's/^ \([A-Z_]*\): \${{ secrets\.\([A-Z_]*\) }}$/\1=\2/p' | sort)" +check "workflow: release gets exactly the signing secrets by name, no 'secrets: inherit'" \ + eval '! grep -q "secrets: inherit" "$workflow" && [[ -n "$declared" && "$passed" == "$(sed "s/.*/&=&/" <<< "$declared")" ]]' +# The guard's presence check is the only other mention, and it yields true/false, never the value. +presence="REPO_SECRET_PRESENT: \${{ secrets.FORK_PUSH_TOKEN != '' }}" +check "workflow: FORK_PUSH_TOKEN reaches only fork-follow-push.sh steps, never a checkout" \ + eval '! grep -q "token: \${{ secrets.FORK_PUSH_TOKEN" "$workflow" && [[ $(grep "secrets.FORK_PUSH_TOKEN" "$workflow" | grep -cvF "$presence") == $(grep -cE "^\s*(run: )?bash \.github/scripts/fork-follow-push\.sh" "$workflow") ]]' +guard_job="$(sed -n '/^ guard:$/,/^ [a-z_-]*:$/p' "$workflow" | sed '$d')" +check "workflow: a Guard job without an Environment sees only whether a repository/organisation secret exists, and runs the guard" \ + eval '[[ -n "$guard_job" ]] && ! grep -q "environment:" <<< "$guard_job" && [[ $(grep -cF "$presence" "$workflow") == 1 ]] && grep -qF "$presence" <<< "$guard_job" && grep -q "fork-follow-guard.sh" <<< "$guard_job" && grep -q "actions: read" <<< "$guard_job"' +plan_head="$(sed -n "/^ plan:$/,/^ steps:$/p" "$workflow")" +advance_head="$(sed -n "/^ advance:$/,/^ steps:$/p" "$workflow")" +attempt_check="needs.guard.outputs.attempt == github.run_attempt" +check "workflow: the plan (the first job with the token) waits for the guard; advance waits for the guard and the plan" \ + eval 'grep -qx " needs: \[guard\]" <<< "$plan_head" && grep -qx " needs: \[guard, plan, release\]" <<< "$advance_head"' +# Round 3: the plan's only job-level condition is this attempt's guard, with the implied success(): +# always(), !cancelled() or failure() there would let it run after the guard refused. +check "workflow: the plan runs only after a guard that passed in this attempt (no always/cancelled/failure)" \ + eval '[[ "$(grep -c "^ if:" <<< "$plan_head")" == 1 ]] && grep -qxF " if: $attempt_check" <<< "$plan_head" && ! grep -qE "always\(\)|cancelled\(\)|failure\(\)" <<< "$plan_head"' +check "workflow: advance needs the plan's success and this attempt's guard" \ + eval 'grep -qF "needs.plan.result == '"'"'success'"'"'" <<< "$advance_head" && grep -qF "$attempt_check" <<< "$advance_head" && ! grep -qE "always\(\)|failure\(\)" <<< "$advance_head"' +check "workflow: the guard's attempt output is set only by a step after the guard script" \ + eval 'grep -qF "attempt: \${{ steps.passed.outputs.attempt }}" <<< "$guard_job" && awk "/fork-follow-guard.sh/ { g = NR } /id: passed/ { p = NR } /attempt=\\\$GITHUB_RUN_ATTEMPT/ { a = NR } END { exit !(g && p > g && a > p) }" <<< "$guard_job"' +advance_job="$(sed -n '/^ advance:$/,/^ [a-z_-]*:$/p' "$workflow" | sed '$d')" +check "workflow: before the move, advance checks the Environment again and sweeps, refusing new unlisted files" \ + eval 'awk "/fork-follow-guard.sh --environment-only/ { g = NR } /REQUIRE_NEW_OFF: \"true\"/ { r = NR } /NEW_WORKFLOWS: .*new_workflows/ && !n { n = NR } /fork-follow-push.sh advance/ { m = NR } END { exit !(g && r && n && m && g < r && r < m && n < m) }" <<< "$advance_job"' +# Round 4: advance's condition is one conjunction: an `||` outside the parentheses (say after the +# attempt check) would let a re-run with an earlier attempt's guard move the branch. +advance_if="$(awk '/^ if: >-$/ { s = 1; next } s && /^ / { printf "%s ", $0; next } s { exit }' <<< "$advance_head")" +top_level="$advance_if" +while [[ "$top_level" =~ \([^()]*\) ]]; do top_level="${top_level//"${BASH_REMATCH[0]}"/_}"; done +check "workflow: advance's if: is a conjunction (no top-level ||) of the plan's success and this attempt's guard" \ + eval '[[ -n "$advance_if" && "$top_level" != *"||"* && "$top_level" == *"$attempt_check &&"* && "$top_level" == *"needs.plan.result == '"'"'success'"'"' &&"* ]]' +# A refusal that only warns is no refusal: continue-on-error is allowed in the Report job alone (the +# guard, the Environment re-check and the sweep before the move must stop their job). +report_start="$(grep -n "^ report:$" "$workflow" | cut -d: -f1)" +check "workflow: continue-on-error appears only in the Report job (never on the guard, the re-check or the sweep before the move)" \ + eval '[[ -n "$report_start" ]] && ! awk -v r="$report_start" "/continue-on-error/ && NR < r { bad = 1 } END { exit !bad }" "$workflow"' +check "workflow: the move step runs only when the checks before it passed (no if:)" \ + eval 'awk "/- name: Move the series branch/ { s = 1; next } s && /^ - / { exit } s && /^ if:/ { bad = 1 } END { exit bad }" <<< "$advance_job"' +check "workflow: action none deletes a leftover candidate branch" \ + eval 'grep -B2 -A3 "action == .none." "$workflow" | grep -q "fork-follow-push.sh cleanup"' +check "workflow: the last sweep reports runs since the run began" \ + eval 'sed -n "/^ report:/,\$p" "$workflow" | grep -q "SINCE: \${{ needs.guard.outputs.started }}"' +# Each job that reads FORK_PUSH_TOKEN runs in the Environment that holds it, named as in env. +token_jobs="$(grep -vF "$presence" "$workflow" | awk '/^ [a-z_-]+:$/ { job = $1 } /secrets\.FORK_PUSH_TOKEN/ { print job }' | sort -u)" +env_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /^ environment: fork-push$/ { print job }' "$workflow" | sort -u)" +check "workflow: the jobs that read FORK_PUSH_TOKEN, and only they, run in the Environment fork-push" \ + eval '[[ -n "$token_jobs" && "$token_jobs" == "$env_jobs" ]] && grep -qx " FORK_PUSH_ENVIRONMENT: fork-push" "$workflow"' +check "workflow: every checkout drops its credentials" \ + eval '[[ $(grep -c "actions/checkout@" "$workflow") == $(grep -c "persist-credentials: false" "$workflow") ]]' +check "workflow: every action is pinned by commit" \ + eval '! grep -E "^\s*-?\s*uses: [^.][^@]*@" "$workflow" | grep -vqE "@[0-9a-f]{40}( |$)"' +check "workflow: the report runs on every run (also action none)" \ + eval 'sed -n "/^ report:/,\$p" "$workflow" | grep -q "^ if: always()$"' +check "workflow: upstream workflows are swept after each push" \ + eval '[[ $(grep -c "fork-workflows.sh" "$workflow") -ge 3 ]]' + +echo +if (( failures )); then + echo "$failures failed" + exit 1 +fi +echo "all passed" diff --git a/.github/scripts/fork-gh-guard.sh b/.github/scripts/fork-gh-guard.sh new file mode 100644 index 0000000000000..cbee67444e3f9 --- /dev/null +++ b/.github/scripts/fork-gh-guard.sh @@ -0,0 +1,51 @@ +# shellcheck shell=bash +# Sourced by every fork script that calls gh (fork-workflows.sh, fork-follow-guard.sh, +# fork-follow-report.sh, fork-release.sh). Those scripts change the live repository (they disable +# workflows, cancel runs, publish releases), so they reach gh only: +# - in a GitHub Actions run (GITHUB_ACTIONS=true) that is not a test, or +# - in a test (FORK_TEST_MODE set, by fork-test-gh.sh) whose gh is the tests' stub: the gh on +# PATH is the file FORK_GH_STUB names and carries the stub's marker line, no GitHub token is +# set, and GH_HOST is a .invalid host. +# Anything else refuses: the script exits 1 when it sources this file, before any gh call, and +# every later call goes through the gh function below, which checks again (a script may change +# PATH). The marker must match FORK_GH_STUB_MARKER in fork-test-gh.sh. Tests: fork-follow.test.sh +# and fork-release.test.sh. + +FORK_GH_STUB_MARKER="# fork-gh-stub: a test stand-in for gh that never reaches GitHub" + +fork_gh_refusal() { # prints why gh may not be called here, or nothing + local stub + if [[ -z "${FORK_TEST_MODE:-}" ]]; then + [[ "${GITHUB_ACTIONS:-}" == true ]] \ + || echo "this is neither a GitHub Actions run nor a test under the gh stub (FORK_TEST_MODE). These scripts change the live repository, so they run only in its workflows." + return 0 + fi + stub="$(type -P gh || true)" + if [[ -z "${FORK_GH_STUB:-}" || -z "$stub" || "$(realpath "$stub")" != "$(realpath "$FORK_GH_STUB")" ]]; then + echo "test mode, but the gh on PATH (${stub:-none}) is not the stub FORK_GH_STUB (${FORK_GH_STUB:-unset})." + elif ! grep -qxF "$FORK_GH_STUB_MARKER" "$stub"; then + echo "test mode, but $stub has no stub marker line." + elif [[ -n "${GH_TOKEN:-}${GITHUB_TOKEN:-}${GH_ENTERPRISE_TOKEN:-}${GITHUB_ENTERPRISE_TOKEN:-}" ]]; then + echo "test mode, but a GitHub token is set (GH_TOKEN, GITHUB_TOKEN or an enterprise token)." + elif [[ "${GH_HOST:-}" != *.invalid ]]; then + echo "test mode, but GH_HOST (${GH_HOST:-unset}) is not a .invalid host." + fi +} + +gh() { + local why + why="$(fork_gh_refusal)" + if [[ -n "$why" ]]; then + printf '::error::Refusing to call gh: %s\n' "$why" >&2 + return 97 + fi + command gh "$@" +} + +fork_gh_why="$(fork_gh_refusal)" +if [[ -n "$fork_gh_why" ]]; then + printf '::error::Refusing to run %s: %s\n' "$(basename "$0")" "$fork_gh_why" >&2 + [[ -z "${REPORT:-}" ]] || printf 'Refusing to run %s: %s\n' "$(basename "$0")" "$fork_gh_why" > "$REPORT" + exit 1 +fi +unset fork_gh_why diff --git a/.github/scripts/fork-release.sh b/.github/scripts/fork-release.sh index 62e62a4816736..f0ff3eee17d38 100755 --- a/.github/scripts/fork-release.sh +++ b/.github/scripts/fork-release.sh @@ -14,6 +14,8 @@ # (owner/name, resolve looks it up), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN # (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout. set -euo pipefail +# Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). +. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-origin}" diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh index 2a1e2240ecba1..aae82c37ded49 100755 --- a/.github/scripts/fork-release.test.sh +++ b/.github/scripts/fork-release.test.sh @@ -20,11 +20,13 @@ export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN # Fake gh: `gh api [-X M] PATH [--paginate] [--jq EXPR] [-f/-F k=v]...` served from $root/api. +# fork_test_gh (fork-test-gh.sh) installs it first on PATH, with no token, a .invalid GH_HOST and +# FORK_TEST_MODE, so the script refuses to run if any other gh would answer. +. "$here/fork-test-gh.sh" mkdir -p "$root/bin" "$root/api" -cat > "$root/bin/gh" <<'GH' -#!/usr/bin/env bash +fork_test_gh "$root/bin" <<'GH' set -euo pipefail -api="$FAKE_API" +api="${FAKE_API:?the gh stub needs FAKE_API}" echo "gh $*" >> "$api/calls" [[ "$1" == api ]] || { echo "fake gh: only api" >&2; exit 2; } shift @@ -55,8 +57,7 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH -chmod +x "$root/bin/gh" -export PATH="$root/bin:$PATH" FAKE_API="$root/api" +export FAKE_API="$root/api" failures=0 pass() { echo "ok $1"; } @@ -287,6 +288,32 @@ check_gt 0.1.9-fork.2 0.1.9-fork.2 not check_gt 0.1.9-fork.2 0.2.0-fork.1 not check_gt v1.0.0 v0.1.10 gt +# --- gh: never GitHub outside a run (fork-gh-guard.sh) ------------------------------------------ +# A gh that is not the stub answers like GitHub and records every call; the script must refuse +# before it calls any gh: neither this one nor the stub. +mkdir -p "$root/decoy" +printf '#!/usr/bin/env bash\necho "gh $*" >> "%s"\necho "[]"\n' "$root/decoy.calls" > "$root/decoy/gh" +chmod +x "$root/decoy/gh" +new_upstream +new_fork v0.1.9 +reset_api v0.1.9 +refuses() { # refuses NAME ENV...: resolve and remove-drafts, run with ENV, fail, say why, call no gh + local name="$1" sub ok=true + shift + for sub in resolve "remove-drafts v0.1.9-fork.1"; do + rm -f "$root/decoy.calls" "$root/api/calls" + # shellcheck disable=SC2086 + if (cd "$root/work" && env "$@" "$script" $sub) > "$root/out" 2>&1; then ok=false; fi + [[ ! -e "$root/decoy.calls" && ! -e "$root/api/calls" ]] || ok=false + grep -q "Refusing to run fork-release.sh" "$root/out" || ok=false + done + if $ok; then pass "$name"; else fail "$name"; fi +} +refuses "gh: a test run without the stub first on PATH fails closed and never calls gh" PATH="$root/decoy:$PATH" +refuses "gh: run by hand (no test mode, not in a GitHub Actions run) refuses" FORK_TEST_MODE= PATH="$root/decoy:$PATH" +refuses "gh: test mode with a GitHub token set refuses, even with the stub" GH_TOKEN=x +refuses "gh: test mode with GH_HOST github.com refuses" GH_HOST=github.com + if (( failures )); then echo "$failures failed" exit 1 diff --git a/.github/scripts/fork-secret-scan.sh b/.github/scripts/fork-secret-scan.sh index b6411894d226a..cfafbb16e848b 100755 --- a/.github/scripts/fork-secret-scan.sh +++ b/.github/scripts/fork-secret-scan.sh @@ -110,6 +110,15 @@ scanned="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | sed -nE 's/.* ([0-9]+) commits echo "gitleaks scanned ${scanned:-no} commit(s); $expected commit(s) in range have changes it reads" summary "### Secret scan" "" if [[ -n "$warning" ]]; then summary "Warning: $warning." ""; fi +# gitleaks 8.30.1 logs git's stderr as ERR (and `ERR error="stderr is not empty"`), stops reading +# diffs once git exits, and still exits 0; the count above misses that when the dropped files all +# belong to a commit it already counted. Any ERR line with exit 0 fails the job. +errors="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | grep -E '^([^[:space:]]+[[:space:]]+)?ERR[[:space:]]' || true)" +if [[ $status -eq 0 && -n "$errors" ]]; then + echo "::error::Secret scan: gitleaks logged an error and still passed, so it may not have read every change: $(head -n 1 <<< "$errors")" >&2 + summary "Failed: gitleaks logged an error while scanning $what (see the job log), so the scan may be incomplete." + exit 2 +fi if [[ $status -eq 0 && ( -z "$scanned" || "$scanned" -lt "$expected" ) ]]; then echo "::error::Secret scan: gitleaks scanned ${scanned:-no} commit(s), but $expected commit(s) in range have changes (a git error stopped it?)" >&2 summary "Failed: gitleaks scanned ${scanned:-no} of the $expected commit(s) with changes in $what, so the rest were not checked." diff --git a/.github/scripts/fork-secret-scan.test.sh b/.github/scripts/fork-secret-scan.test.sh index e5a0c191a1291..f0022f7275e1a 100755 --- a/.github/scripts/fork-secret-scan.test.sh +++ b/.github/scripts/fork-secret-scan.test.sh @@ -38,6 +38,11 @@ if ! git log -p -U0 $opts > "$TMPDIR/fake-gitleaks.log" 2> "$TMPDIR/fake-gitleak echo "no leaks found" exit 0 fi +# Like gitleaks 8.30.1: any stderr line from git is logged as an error, and the scan goes on. +if [[ -s "$TMPDIR/fake-gitleaks.err" ]]; then + echo "ERR [git] $(head -n 1 "$TMPDIR/fake-gitleaks.err")" + echo 'ERR error="stderr is not empty"' +fi # A commit counts when a file it does not delete has a hunk, as in gitleaks. scanned="$(awk '/^commit / { c = $2 } /^diff --git / { d = 0 } /^deleted file mode / { d = 1 } /^@@ / && !d && !(c in s) { s[c] = 1; n++ } END { print n + 0 }' "$TMPDIR/fake-gitleaks.log")" @@ -187,6 +192,39 @@ BASE_SHA="$base" HEAD_SHA="$leaky" \ check "pull request: contents that cannot be fetched after the clone fail" 2 "cannot read the commits" mv "$root/origin-gone.git" "$root/origin.git" +# git writes a stray line to stderr while gitleaks reads the log (in gitleaks 8.30.1 that is +# `ERR error="stderr is not empty"`, possibly with diffs dropped, and still exit 0): the scan fails. +mkdir -p "$root/straygit" +cat > "$root/straygit/git" <&2 +exit \$status +STRAY +chmod +x "$root/straygit/git" +clone="$root/clone" +PATH="$root/straygit:$PATH" BASE_SHA="$base" HEAD_SHA="$clean" \ + check "pull request: gitleaks logging a git error (stderr not empty) and passing fails" 2 "gitleaks logged an error|gitleaks scanned" +BASE_SHA="$base" HEAD_SHA="$clean" check "pull request: without the stray line the same scan passes" 0 +# The case the count misses (gitleaks 8.30.1): one commit adds 3,000 files and a key in the last +# one; the stray line stops gitleaks before that file, with the commit already counted. The real +# gitleaks then exits 0, so the scan must fail on its ERR lines (2); the stand-in reads every file +# and finds the key (1). +cd "$root/work" +git checkout -q -b wide "$base" +mkdir -p wide && for n in $(seq -w 1 3000); do echo "line $n" > "wide/f$n.txt"; done +printf '%s\n' "aws_access_key_id=$key" > wide/zzz.txt +date=$((date + 60)) +git add -A && GIT_AUTHOR_DATE="@$date" GIT_COMMITTER_DATE="@$date" git commit -qm "PR W: 3,000 files, a key last" +git push -q origin wide +wide=$(git rev-parse wide) +git -C "$root/clone" fetch -q origin wide +cd "$root" +wide_status=1; [[ -z "${GITLEAKS_BIN:-}" ]] || wide_status=2 +PATH="$root/straygit:$PATH" BASE_SHA="$base" HEAD_SHA="$wide" \ + check "pull request: a stray git stderr line that hides a key in a wide commit fails" "$wide_status" + if [[ "$failures" -ne 0 ]]; then echo "$failures check(s) failed" exit 1 diff --git a/.github/scripts/fork-test-gh.sh b/.github/scripts/fork-test-gh.sh new file mode 100644 index 0000000000000..15d59ed594837 --- /dev/null +++ b/.github/scripts/fork-test-gh.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# Sourced first by every fork test that runs a script calling gh (fork-follow.test.sh, +# fork-release.test.sh). `fork_test_gh DIR < STUB` cuts the test off from GitHub before any script +# runs: no GitHub token, GH_HOST a .invalid host and an empty gh config (so even a real gh has +# nothing to reach and no login), and the stub read from stdin as DIR/gh, first on PATH, with the +# marker line fork-gh-guard.sh looks for. FORK_TEST_MODE then makes the scripts refuse whenever +# that stub is not the gh they would call. The test stops at once if gh still resolves elsewhere. + +FORK_GH_STUB_MARKER="# fork-gh-stub: a test stand-in for gh that never reaches GitHub" + +fork_test_gh() { + local dir="$1" + unset GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN GITHUB_ACTIONS + mkdir -p "$dir" "$dir/gh-config" + export GH_HOST=gh-stub.invalid GH_CONFIG_DIR="$dir/gh-config" GH_PROMPT_DISABLED=1 \ + GH_NO_UPDATE_NOTIFIER=1 FORK_TEST_MODE=1 + { + echo '#!/usr/bin/env bash' + echo "$FORK_GH_STUB_MARKER" + cat + } > "$dir/gh" + chmod +x "$dir/gh" + export PATH="$dir:$PATH" FORK_GH_STUB="$dir/gh" + if [[ "$(type -P gh)" != "$dir/gh" ]]; then + echo "fork_test_gh: gh resolves to $(type -P gh), not the stub $dir/gh; stopping before any script runs" >&2 + exit 1 + fi +} diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh new file mode 100755 index 0000000000000..5bde87d0366c2 --- /dev/null +++ b/.github/scripts/fork-workflows.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Keeps upstream's workflows off in this fork (tests: fork-follow.test.sh). Disables every active +# workflow under .github/workflows that is not one of the fork's own (OWN_WORKFLOWS, and every +# .github/workflows/fork-*.y[a]ml), cancels every unfinished run of such a workflow (whatever event +# started it: push, pull_request, schedule ...), and names what it could not stop: +# finished runs of upstream workflows that completed since SINCE (they ran before a sweep +# could stop them), with their conclusion +# unregistered upstream workflow files in the tree at TREE_REF that GitHub does not list yet +# and that are new or changed on this upstream release (NEW_WORKFLOWS). GitHub +# lists a workflow only once an event has reached its file, so these can be neither +# disabled nor cancelled until then; the next sweep after that event does. +# unregistered_old the other unlisted upstream files, from earlier releases. Most of them run +# only on events the fork never sends (workflow_call, workflow_dispatch, +# workflow_run), so they stay unlisted; they are named, but not as a warning. +# +# fork-follow.yml runs this right after each of its pushes, right before it moves the series +# branch (REQUIRE_NEW_OFF=true), and again at the end of the run. +# +# Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), OWN_WORKFLOWS (','-separated +# paths the fork's own commits add; required, nothing is guessed), NEW_WORKFLOWS (','-separated +# upstream files added or changed since the series' old base; optional), REQUIRE_NEW_OFF (true: fail when one +# of NEW_WORKFLOWS is in the tree and not listed, because then it cannot be disabled before the +# series branch has it), TREE_REF (commit or branch whose workflow files are checked; default +# HEAD_SHA, else SERIES_BRANCH, else main), HEAD_SHA, SERIES_BRANCH, PREVIOUS_HEAD (the series head +# the move starts from, for the advice), SINCE (ISO 8601 time; optional), REPORT (receives the +# reason on failure). Output: disabled=, cancelled=, finished=, unregistered= and unregistered_old= +# (','-separated) to $GITHUB_OUTPUT, else stdout; a summary line on stderr, and a ::warning:: line +# for finished runs and new unregistered files. +set -euo pipefail +# Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). +. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +REPORT="${REPORT:-fork-workflows-report.md}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +TREE_REF="${TREE_REF:-${HEAD_SHA:-${SERIES_BRANCH:-main}}}" + +die() { + printf '%s\n' "$@" > "$REPORT" + printf '::error::%s\n' "$1" >&2 + [[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2 + exit 1 +} + +[[ -n "${OWN_WORKFLOWS:-}" ]] || die "The list of the fork's own workflows is empty, so no workflow was disabled (refusing to guess)." +# A fork-*.yml or fork-*.yaml file is the fork's own even when the series does not have it yet (a +# fork pull request adding it runs it from its branch): it is never disabled, nor its runs +# cancelled. The plan refuses an upstream release that has such a file (fork-follow.sh). +own() { [[ ",$OWN_WORKFLOWS," == *",$1,"* || "$1" =~ ^\.github/workflows/fork-[^/]*\.ya?ml$ ]]; } +upstream() { [[ "$1" == .github/workflows/* ]] && ! own "$1"; } +new() { [[ ",${NEW_WORKFLOWS:-}," == *",$1,"* ]]; } + +listed="$(gh api "repos/$REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | "\(.id) \(.state) \(.path)"')" \ + || die "Could not list the workflows of $REPOSITORY, so upstream workflows may still be enabled." + +disabled=() +while read -r id state path; do + [[ -n "${id:-}" && "$state" == active ]] && upstream "$path" || continue + gh api -X PUT "repos/$REPOSITORY/actions/workflows/$id/disable" > /dev/null \ + || die "Could not disable the upstream workflow $path ($id)." + disabled+=("$path") +done <<< "$listed" + +# Unfinished runs, from any event and for any commit; one listing per unfinished status. +cancelled=() +runs="" +for status in requested queued pending waiting in_progress; do + runs+="$(gh api "repos/$REPOSITORY/actions/runs?status=$status&per_page=100" --paginate \ + --jq '.workflow_runs[] | select(.status != "completed") | "\(.id) \(.path)"')"$'\n' \ + || die "Could not list the $status runs of $REPOSITORY, so runs of upstream workflows may still be going." +done +while read -r id path; do + path="${path%%@*}" + [[ -n "${id:-}" ]] && upstream "$path" || continue + if ! gh api -X POST "repos/$REPOSITORY/actions/runs/$id/cancel" > /dev/null; then + status="$(gh api "repos/$REPOSITORY/actions/runs/$id" --jq .status)" \ + || die "Could not cancel run $id of $path, nor read its state." + [[ "$status" == completed ]] || die "Could not cancel run $id of $path (state: $status)." + continue + fi + cancelled+=("$id ($path)") +done <<< "$(sort -u <<< "$runs")" + +# Runs that finished before a sweep could stop them. Cancelled ones are left out: a sweep stopped +# them (named under cancelled) or a person did. +finished=() +if [[ -n "${SINCE:-}" ]]; then + done_runs="$(gh api "repos/$REPOSITORY/actions/runs?created=%3E%3D$SINCE&status=completed&per_page=100" --paginate \ + --jq '.workflow_runs[] | select(.status == "completed" and .conclusion != "cancelled") | "\(.id) \(.conclusion) \(.event) \(.path)"')" \ + || die "Could not list the runs of $REPOSITORY since $SINCE, so upstream runs that already finished are not known." + while read -r id conclusion event path; do + path="${path%%@*}" + [[ -n "${id:-}" ]] && upstream "$path" || continue + finished+=("$id ($path: $conclusion after $event)") # no ',': lists are ','-joined + done <<< "$(sort -u <<< "$done_runs")" +fi + +# Upstream workflow files GitHub has not registered (any state counts as registered). +unregistered=() +unregistered_old=() +files="$(gh api "repos/$REPOSITORY/contents/.github/workflows?ref=$TREE_REF" \ + --jq '.[] | select(.type == "file") | .path')" \ + || die "Could not list the workflow files of $REPOSITORY at $TREE_REF, so unregistered upstream workflows are not known." +while read -r path; do + [[ "$path" == *.yml || "$path" == *.yaml ]] && upstream "$path" || continue + awk -v p="$path" '$3 == p { found = 1 } END { exit !found }' <<< "$listed" && continue + if new "$path"; then unregistered+=("$path"); else unregistered_old+=("$path"); fi +done <<< "$files" + +join() { local IFS=,; echo "$*"; } +{ + echo "disabled=$(join "${disabled[@]}")" + echo "cancelled=$(join "${cancelled[@]}")" + echo "finished=$(join "${finished[@]}")" + echo "unregistered=$(join "${unregistered[@]}")" + echo "unregistered_old=$(join "${unregistered_old[@]}")" +} >> "$OUTPUT" +((${#finished[@]} == 0)) \ + || echo "::warning::Upstream workflow runs finished before they could be stopped: $(join "${finished[@]}")" >&2 +((${#unregistered[@]} == 0)) \ + || echo "::warning::Upstream workflow files new or changed on this release that GitHub does not list yet, so not disabled: $(join "${unregistered[@]}")" >&2 +echo "Upstream workflows disabled: ${#disabled[@]}; runs cancelled: ${#cancelled[@]}; finished before a sweep: ${#finished[@]}; new and not registered yet: ${#unregistered[@]}; older and not registered: ${#unregistered_old[@]}." >&2 + +# Before the series branch moves: any workflow file on it may ask for the Environment that holds +# FORK_PUSH_TOKEN, and the push starts its push workflows before a later sweep can stop them. A +# new upstream file that GitHub does not list cannot be disabled first, so a person decides. +if [[ "${REQUIRE_NEW_OFF:-}" == true ]] && ((${#unregistered[@]})); then + branch="${SERIES_BRANCH:-main}" + die "$branch was not moved: upstream workflow files that are new or changed on this release are not listed by GitHub yet, so they could not be disabled before $branch gets them:" \ + "$(printf -- '- `%s`\n' "${unregistered[@]}")" "" \ + "On $branch, a push starts their push workflows before a sweep can stop them, and any workflow file there may ask for the Environment that holds FORK_PUSH_TOKEN." \ + "A person decides. Read them in the candidate (\`git show ${TREE_REF:0:12}:\`). If they are harmless, move $branch to $TREE_REF yourself" \ + "(\`git push --force-with-lease=refs/heads/$branch:${PREVIOUS_HEAD:-} https://github.com/$REPOSITORY.git $TREE_REF:refs/heads/$branch\`)" \ + "and right after start the follow workflow once (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`): it disables them and cancels their runs." \ + "Otherwise add a fork patch that deletes them; the next run then moves $branch." +fi diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml new file mode 100644 index 0000000000000..8be10d65cc509 --- /dev/null +++ b/.github/workflows/fork-follow.yml @@ -0,0 +1,413 @@ +name: Fork follow upstream + +# Daily: keeps this fork's patch series on upstream's newest stable release and publishes it. +# The plan (.github/scripts/fork-follow.sh) picks one action: +# - none: the series head is already the newest fork release of its base. +# - release: same base, but the series has commits no fork release has: test, build and publish. +# - rebase: upstream has a newer stable tag: rebase the series onto it, test, build, publish, then +# move the series branch to the released commit (the old series is kept as a tag). +# - advance: a published fork release already has the rebased files (an earlier run published it, +# then could not move the branch): move the series branch to it, build nothing. +# - stage: the release workflows of this run's commit differ from the candidate's (upstream changed +# them, or this is a re-run or queued run of an older commit). A run uses the reusable workflows +# of its own commit, so the candidate is pushed and the run stops with the command that releases +# it with its own files; the next run then finds that release (and advances to it). +# A conflict, merge commits in the series, a schema change, upstream commits inside the series, a +# moved or deleted upstream tag, a red test, a failed build or push: the run fails, and the Report +# job's summary says why and what the repository holds now (branch heads, release tags, drafts), as +# read back from it. With Issues enabled it also opens or updates one issue, and closes it again +# on the next good run. +# +# Upstream's workflows stay off: after each push, before the series branch moves, and at the end, +# fork-workflows.sh disables every active workflow the series did not add (a fork-*.yml is always the +# fork's own, also before main has it; the plan refuses an upstream release that has one), cancels +# every unfinished run of one (any event), and names the runs that finished before it could stop +# them and the files GitHub has not listed yet. The branch move is refused while an upstream +# workflow file that is new or changed on this release is not listed (it could not be disabled, and +# on the branch it could ask for fork-push); a person decides then. +# +# A re-run of failed jobs (or of one job) keeps the Guard job's earlier result, so the jobs with +# the token are skipped unless the guard passed in this attempt: use "Re-run all jobs". +# +# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN, only in the +# Environment fork-push, whose deployment branches are "Selected branches and tags" with the one +# rule: branch . A repository secret could be read by a workflow file on any pushed +# branch, and an Environment without that rule hands its secret to any branch. The Guard job checks +# both before any job gets the token (fork-follow-guard.sh) and refuses, saying what to change. +# The token: a fine-grained token for this repository only, Contents and Workflows read and write +# (GITHUB_TOKEN may not push commits that change .github/workflows). The plan checks it first and +# fails when it is missing, expired or may not push. It reaches only the steps that run +# fork-follow-push.sh; the release workflow gets the signing secrets by name and never this token. +# If a rule protects the series branch, the token's user must be allowed to bypass it. +# +# A conflict is resolved by hand: rebase the series branch onto the new upstream tag, resolve, test, +# push it with --force-with-lease, then start this workflow once: it releases the series and disables +# the upstream workflows that push started (the plan's report has the commands). +on: + schedule: + - cron: "23 6 * * *" + workflow_dispatch: + +concurrency: + group: fork-follow + cancel-in-progress: false + +permissions: + contents: read + +env: + SERIES_BRANCH: ${{ vars.FORK_SERIES_BRANCH || 'main' }} + # Must match `environment:` of the jobs that push (an expression there cannot read env). + FORK_PUSH_ENVIRONMENT: fork-push + +jobs: + # No Environment here, so the FORK_PUSH_TOKEN secret seen here can only be a repository or + # organisation secret. Only whether it is empty reaches the step, never the value. + guard: + name: Guard the token + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + started: ${{ steps.started.outputs.at }} + # Set only once the guard passed. The jobs that get the token run only when it is this run + # attempt: "Re-run failed jobs" or "Re-run job" keeps an earlier attempt's guard result + # without running the guard again, and the settings may have changed since. + attempt: ${{ steps.passed.outputs.attempt }} + steps: + - id: started + name: Note the start + run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - name: Checkout the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: true + + - name: Refuse unless only the Environment holds FORK_PUSH_TOKEN, for the series branch only + env: + GH_TOKEN: ${{ github.token }} + REPO_SECRET_PRESENT: ${{ secrets.FORK_PUSH_TOKEN != '' }} + REPORT: ${{ runner.temp }}/reports/guard.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-guard.sh + + - id: passed + name: Note the attempt the guard passed in + run: echo "attempt=$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-follow-report-guard + path: ${{ runner.temp }}/reports + if-no-files-found: ignore + retention-days: 7 + + # Job-level if (implied success()): a skipped job never reaches the Environment, so GitHub + # neither hands it the token nor auto-creates the Environment. + plan: + name: Plan + needs: [guard] + if: needs.guard.outputs.attempt == github.run_attempt + environment: fork-push + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: write + contents: read + outputs: + action: ${{ steps.plan.outputs.action }} + base_tag: ${{ steps.plan.outputs.base_tag }} + previous_tag: ${{ steps.plan.outputs.previous_tag }} + candidate: ${{ steps.plan.outputs.candidate }} + previous_head: ${{ steps.plan.outputs.previous_head }} + released_tag: ${{ steps.plan.outputs.released_tag }} + dropped: ${{ steps.plan.outputs.dropped }} + own_workflows: ${{ steps.plan.outputs.own_workflows }} + new_workflows: ${{ steps.plan.outputs.new_workflows }} + disabled: ${{ steps.sweep_plan.outputs.disabled }},${{ steps.sweep_push.outputs.disabled }} + cancelled: ${{ steps.sweep_push.outputs.cancelled }} + steps: + - name: Checkout the series + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ env.SERIES_BRANCH }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Test the follow scripts + run: bash .github/scripts/fork-follow.test.sh + + - name: Check FORK_PUSH_TOKEN + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + REPORT: ${{ runner.temp }}/reports/token.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-push.sh check + + - id: plan + name: Plan and rebase + env: + GH_TOKEN: ${{ github.token }} + RUN_SHA: ${{ github.sha }} + REPORT: ${{ runner.temp }}/reports/plan.md + run: | + set -euo pipefail + UPSTREAM="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.parent.full_name // empty')" + if [[ -z "$UPSTREAM" ]]; then + echo "$GITHUB_REPOSITORY is not a fork, so there is no upstream to follow." > "$REPORT" + exit 1 + fi + export UPSTREAM + bash .github/scripts/fork-follow.sh + + - id: sweep_plan + name: Disable upstream workflows + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ steps.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ steps.plan.outputs.new_workflows }} + REPORT: ${{ runner.temp }}/reports/plan-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - id: push + name: Push the rebased series as a candidate + if: steps.plan.outputs.action == 'rebase' || steps.plan.outputs.action == 'stage' + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + CANDIDATE: ${{ steps.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/push.md + run: bash .github/scripts/fork-follow-push.sh candidate "$CANDIDATE" + + # The candidate branch is a push: an upstream workflow it brings is listed only from now on. + - id: sweep_push + name: Disable upstream workflows the candidate brought, cancel their runs + if: steps.push.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ steps.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ steps.plan.outputs.new_workflows }} + HEAD_SHA: ${{ steps.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/push-workflows.md + run: bash .github/scripts/fork-workflows.sh + + # Nothing waits for a candidate when the series head is already released. + - name: Delete a leftover candidate branch + if: steps.plan.outputs.action == 'none' + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + run: bash .github/scripts/fork-follow-push.sh cleanup + + - name: Stop for a release from the candidate's own workflow files + if: steps.plan.outputs.action == 'stage' + env: + REPORT: ${{ runner.temp }}/reports/plan.md + run: | + cat "$REPORT" >&2 + exit 1 + + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-follow-report-plan + path: ${{ runner.temp }}/reports + if-no-files-found: ignore + retention-days: 7 + + release: + name: Release + needs: [plan] + if: needs.plan.outputs.action == 'release' || needs.plan.outputs.action == 'rebase' + permissions: + actions: read + contents: write + uses: ./.github/workflows/fork-release.yml + # By name, only what fork-release.yml declares (release-desktop.yml's signing secrets). + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + with: + ref: ${{ needs.plan.outputs.candidate }} + publish: true + + advance: + name: Move the series branch + environment: fork-push + needs: [guard, plan, release] + if: >- + !cancelled() && needs.plan.result == 'success' && + needs.guard.outputs.attempt == github.run_attempt && + (needs.plan.outputs.action == 'advance' || + (needs.plan.outputs.action == 'rebase' && needs.release.result == 'success' && + needs.release.outputs.published == 'true')) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + outputs: + disabled: ${{ steps.sweep.outputs.disabled }} + cancelled: ${{ steps.sweep.outputs.cancelled }} + steps: + - name: Checkout the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.plan.outputs.candidate }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + # The release can take an hour or more, so the Environment's rule is checked again. (The + # repository secret cannot be checked from inside the Environment; the Guard job did.) + - name: Check again that only the series branch may deploy to fork-push + env: + GH_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/reports/advance-guard.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-guard.sh --environment-only + + # Any workflow file on the series branch may ask for fork-push, and the move fires its push + # workflows. So upstream workflows are disabled first, and the move is refused while an + # upstream file new or changed on this release is not listed by GitHub (it cannot be + # disabled yet). + - id: sweep_before + name: Disable upstream workflows before the move, refuse while a new one cannot be + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ needs.plan.outputs.new_workflows }} + REQUIRE_NEW_OFF: "true" + HEAD_SHA: ${{ needs.plan.outputs.candidate }} + PREVIOUS_HEAD: ${{ needs.plan.outputs.previous_head }} + REPORT: ${{ runner.temp }}/reports/advance-before.md + run: bash .github/scripts/fork-workflows.sh + + # Refuses if the branch moved since the plan (a fork PR merged meanwhile); the next run + # rebases again with that change. A re-run after a successful move finds it done. + - name: Move the series branch to the released commit + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + BASE_TAG: ${{ needs.plan.outputs.base_tag }} + PREVIOUS_HEAD: ${{ needs.plan.outputs.previous_head }} + CANDIDATE: ${{ needs.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/advance.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-push.sh advance "$PREVIOUS_HEAD" "$CANDIDATE" + + # The push to the series branch fires push workflows; an upstream one it brought is disabled + # and its run cancelled here. + - id: sweep + name: Disable upstream workflows, cancel their runs + if: always() + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ needs.plan.outputs.new_workflows }} + HEAD_SHA: ${{ needs.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/advance-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - name: Delete the candidate branch + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + run: bash .github/scripts/fork-follow-push.sh cleanup + + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-follow-report-advance + path: ${{ runner.temp }}/reports + if-no-files-found: ignore + retention-days: 7 + + report: + name: Report + needs: [guard, plan, release, advance] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + issues: write + steps: + - name: Checkout the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: true + + - name: Download the jobs' reasons + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: fork-follow-report-* + merge-multiple: true + path: ${{ runner.temp }}/reports + + # Last sweep: runs that a push started after the earlier sweeps looked, the upstream runs that + # finished since the run began, and the upstream files GitHub has not listed yet. + - id: sweep + name: Disable upstream workflows, cancel their runs + if: needs.plan.outputs.own_workflows != '' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ needs.plan.outputs.new_workflows }} + HEAD_SHA: ${{ needs.plan.outputs.candidate }} + SINCE: ${{ needs.guard.outputs.started }} + REPORT: ${{ runner.temp }}/reports/report-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - name: Summary, issue, and the run's status + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTION: ${{ needs.plan.outputs.action }} + GUARD_RESULT: ${{ needs.guard.result }} + GUARD_ATTEMPT: ${{ needs.guard.outputs.attempt }} + RUN_ATTEMPT: ${{ github.run_attempt }} + PLAN_RESULT: ${{ needs.plan.result }} + RELEASE_RESULT: ${{ needs.release.result }} + ADVANCE_RESULT: ${{ needs.advance.result }} + SWEEP_RESULT: ${{ steps.sweep.outcome }} + BASE_TAG: ${{ needs.plan.outputs.base_tag }} + PREVIOUS_TAG: ${{ needs.plan.outputs.previous_tag }} + PREVIOUS_HEAD: ${{ needs.plan.outputs.previous_head }} + CANDIDATE: ${{ needs.plan.outputs.candidate }} + RELEASED_TAG: ${{ needs.plan.outputs.released_tag }} + DROPPED: ${{ needs.plan.outputs.dropped }} + DISABLED: ${{ needs.plan.outputs.disabled }},${{ needs.advance.outputs.disabled }},${{ steps.sweep.outputs.disabled }} + CANCELLED: ${{ needs.plan.outputs.cancelled }},${{ needs.advance.outputs.cancelled }},${{ steps.sweep.outputs.cancelled }} + FINISHED: ${{ steps.sweep.outputs.finished }} + UNREGISTERED: ${{ steps.sweep.outputs.unregistered }} + UNREGISTERED_OLD: ${{ steps.sweep.outputs.unregistered_old }} + REPORTS: ${{ runner.temp }}/reports + run: bash .github/scripts/fork-follow-report.sh From 537e3ed3f944a6fa33f69506413d268076030f43 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:37:15 +0100 Subject: [PATCH 07/27] test(web): command palette tests clear a leaked last-used project (#35107) (#11) Upstream's own fix, cherry-picked unchanged. Browser test files share one origin's localStorage, so a last-used project left by an earlier file hides the "New thread in " row. "New thread in..." then becomes the first row and ArrowDown moves past it, which fails Test Web 2 on fork PRs whenever such a file runs before CommandPalette.browser.test.tsx in the same worker. (cherry picked from commit 5fae2b5124d8c491f242c004a6c6ef8602517719) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/CommandPalette.browser.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index c7106f0cff289..8292121ac2ceb 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -5,6 +5,8 @@ import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { LAST_USED_PROJECT_STORAGE_KEY } from "../hooks/useHandleNewThread"; +import { removeLocalStorageItem } from "../hooks/useLocalStorage"; import { makeThreadFixture } from "../test-fixtures"; import type { Project, Thread } from "../types"; import { CommandPalette } from "./CommandPalette"; @@ -20,6 +22,9 @@ vi.mock("../state/entities", async (original) => ({ })); function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArray } = {}) { + // Earlier browser test files share this origin's localStorage; a remembered project + // that is not listed here hides the default "New thread in " row. + removeLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY); useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); entities.projects = state.projects ?? []; entities.threads = state.threads ?? []; From 919556a1a0707190f156adeeee93ebce5f5010ba Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:15:54 +0100 Subject: [PATCH 08/27] fork: outside PRs run nothing, one checkout without actions/checkout, contract tests (#12) * fork: outside PRs run nothing, one checkout without actions/checkout, contract tests - fork-tests.yml, fork-checks.yml: every job runs only for a pull request whose head is in this repository (no pull_request_target run for outside forks). - fork-release.yml, fork-follow.yml: the eight actions/checkout steps (persist-credentials: false fails on upstream's gitlinks without .gitmodules) are one shared plain-git step: anonymous fetch of one ref, full (blob:none) or tip history, optional sparse set, fails closed. - fork-contracts.test.py: the pull request guard on every PR-started job and an allowlist of fork triggers; the shared checkout run end to end on a repository with a gitlink and no .gitmodules; no refusal in fork-follow.yml swallowed (|| true, || :, ; true, set +e, trap, continue-on-error, if:, another shell, BASH_ENV); every script calling gh sources fork-gh-guard.sh, every shell test sets up the gh stub first. Each rule with broken copies. - fork-schema-check.test.cjs: whole run: | blocks are scanned for ${{ }}, github.token banned. - The three shell tests set up the gh stub before anything else; fork-secret-scan.test.sh gets one that refuses every call. Co-Authored-By: Claude Opus 5.5 * fork: fix round r2 (outside PRs fail, refusal whitelist, branch-only fetch) - An outside pull request now fails: a new outside_pr job (fork-tests.yml and fork-checks.yml) checks nothing out, has permissions {} and exits 1. fork_ci_result runs with always(), needs outside_pr and refuses an outside pull request first. A job skipped by its if: counts as passed, so before this a required fork-ci-result went green on an untested outside pull request. - fork-contracts.test.py, refusals: a refusal step may hold only the report folder and its script call (a whitelist). Earlier steps in its job may be only the shared checkout, another refusal or one step-output line. Nothing may write GITHUB_ENV, GITHUB_PATH or BASH_ENV, define a function or use an action. No BASH_FUNC_* env. - gh-guard: backslash continuations are joined before matching. The guard line must stand at the top level, after nothing but #!, set and comments. - fork-schema-check.test.cjs: runScripts reads `run:` alone with the script on the next line, and plain scalars continued over later lines. - The shared checkout fetches refs/heads/ unless REF is a 40-hex SHA, so a tag named like the branch is never taken. e2e: a tag `main` on an older commit. - Comments: fork-tests.yml says the guard stops only honest pull requests (the PR's own copy runs under pull_request; the approval setting protects). The shared step's comment no longer claims fork-tests.yml uses it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 1 + .github/scripts/fork-contracts.test.py | 757 ++++++++++++++++++ .github/scripts/fork-follow.test.sh | 50 +- .github/scripts/fork-gh-guard.sh | 2 +- .github/scripts/fork-release-workflow.test.py | 19 +- .github/scripts/fork-release.test.sh | 26 +- .github/scripts/fork-schema-check.test.cjs | 136 +++- .github/scripts/fork-secret-scan.test.sh | 12 +- .github/scripts/fork-test-gh.sh | 4 +- .github/workflows/fork-checks.yml | 21 + .github/workflows/fork-follow.yml | 139 +++- .github/workflows/fork-release.yml | 136 +++- .github/workflows/fork-tests.yml | 67 +- 13 files changed, 1246 insertions(+), 124 deletions(-) create mode 100644 .github/scripts/fork-contracts.test.py diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 37279dfa8c754..9947547722dcc 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -6,6 +6,7 @@ # A present test that is not listed fails. A listed file that is not present yet # is a note; one that was present and then deleted fails. .github/scripts/fork-ci-tests.test.cjs +.github/scripts/fork-contracts.test.py .github/scripts/fork-follow.test.sh .github/scripts/fork-release-workflow.test.py .github/scripts/fork-release.test.sh diff --git a/.github/scripts/fork-contracts.test.py b/.github/scripts/fork-contracts.test.py new file mode 100644 index 0000000000000..639dc818b2c27 --- /dev/null +++ b/.github/scripts/fork-contracts.test.py @@ -0,0 +1,757 @@ +"""Rules every fork workflow and fork script keeps, each checked on the real files and on broken +copies that must fail (so a check that stops seeing its problem goes red too): + + pr-guard every job of a workflow a pull request can start runs only for a branch in this + repository, except `outside_pr`, which runs only for another fork's pull request, + checks nothing out and fails, and fork-tests.yml's fork_ci_result, which needs it and + fails there too (a job skipped by its if: counts as passed); a fork workflow takes no + trigger where that cannot be told. + checkout no actions/checkout with persist-credentials: false (its cleanup fails on upstream's + gitlinks); every plain-git checkout is the one shared step, run here end to end + against a repository with a gitlink, no .gitmodules and a tag named like the branch. + refusals in fork-follow.yml no refusal is swallowed: a refusal step is only its script call + (a whitelist), and no step before it in its job can change the shell (GITHUB_ENV, + GITHUB_PATH, BASH_ENV, a function, an action). Other script steps keep a blacklist. + gh-guard every fork script that calls gh sources fork-gh-guard.sh at the top level, after + nothing but `set`, before its first call (backslash continuations joined), and + every fork shell test sets up the gh stub (fork-test-gh.sh) before anything else. + + python3 .github/scripts/fork-contracts.test.py + +Needs PyYAML, git and bash. Each check prints ok/FAIL; any FAIL exits 1. +""" + +import copy +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + +import yaml + +root = pathlib.Path(__file__).resolve().parents[2] +workflows_dir = root / ".github/workflows" +scripts_dir = root / ".github/scripts" +failures = [] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name + ("" if ok or not detail else f"\n | {detail}")) + if not ok: + failures.append(name) + + +def load(path): + return yaml.safe_load(path.read_text()) + + +def fork_workflows(): + return {p.name: load(p) for p in sorted(workflows_dir.glob("fork-*.y*ml"))} + + +def triggers(wf): + on = wf.get(True, wf.get("on")) # YAML 1.1 reads the key `on` as true + if isinstance(on, str): + return {on} + return set(on or []) + + +def steps(wf): + for job_id, job in (wf.get("jobs") or {}).items(): + for step in job.get("steps") or []: + yield job_id, step + + +def expression(value): + text = str(value).strip() + m = re.fullmatch(r"\$\{\{(.*)\}\}", text, re.S) + return (m.group(1) if m else text).strip() + + +# --- pr-guard ---------------------------------------------------------------------------------- +PR_GUARD = "(!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository)" +PR_EVENTS = {"pull_request", "pull_request_target"} +# Events whose payload has no pull_request for the guard to read, although a pull request from +# another fork can cause them (issue_comment, workflow_run, pull_request_review ...), are refused. +ALLOWED_EVENTS = PR_EVENTS | {"push", "schedule", "workflow_dispatch", "workflow_call"} + + +def one_group(text): + """True when text is one parenthesised group: '(' ... ')' with the first '(' closed last.""" + if not text.startswith("(") or not text.endswith(")"): + return False + depth, quoted = 0, False + for i, c in enumerate(text): + if c == "'": + quoted = not quoted + elif not quoted and c in "()": + depth += 1 if c == "(" else -1 + if depth == 0 and i != len(text) - 1: + return False + return depth == 0 and not quoted + + +def guarded(condition): + if condition is None: + return False + expr = expression(condition) + return expr == PR_GUARD or (expr.startswith(PR_GUARD + " && ") and one_group(expr[len(PR_GUARD) + 4 :])) + + +# The job a pull request from another fork gets instead: it fails and runs nothing else. A job +# skipped by its if: counts as passed for a required check, so without it an untested outside pull +# request would show green. +OUTSIDE_JOB = "outside_pr" +OUTSIDE_IF = "github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository" +OUTSIDE_RUN = 'echo "::error::outside PR: not tested here; a maintainer must review"\nexit 1\n' +# fork-tests.yml's result job (the check a ruleset requires) runs on outside pull requests too: it +# needs outside_pr, checks nothing out and refuses first. +RESULT_JOBS = {"fork-tests.yml": "fork_ci_result"} +RESULT_REFUSAL = ('if [[ "$OUTSIDE_PR" == true ]]; then\n' + ' echo "::error::outside PR: not tested here; a maintainer must review"\n' + ' exit 1\n' + 'fi\n') + + +def outside_job_problems(name, job): + where = f"{name}: job {OUTSIDE_JOB}" + if job is None: + return [f"{where} is missing (an outside pull request would skip every job and show green)"] + problems = [] + job_steps = job.get("steps") or [] + if expression(job.get("if")) != OUTSIDE_IF: + problems.append(f"{where}: if is not exactly `{OUTSIDE_IF}`") + if not set(job) <= {"name", "if", "runs-on", "timeout-minutes", "permissions", "steps"} \ + or job.get("permissions") != {}: + problems.append(f"{where}: more than name, if, runs-on, timeout-minutes, permissions {{}} and steps") + if len(job_steps) != 1 or not set(job_steps[0]) <= {"name", "run"} or job_steps[0].get("run") != OUTSIDE_RUN: + problems.append(f"{where}: not exactly one step that prints the refusal and exits 1") + return problems + + +def result_job_problems(name, job): + where = f"{name}: job {RESULT_JOBS[name]}" + if job is None: + return [f"{where} is missing"] + problems = [] + needs = job.get("needs") or [] + if expression(job.get("if")) != "always()": + problems.append(f"{where}: if is not exactly always() (skipped on an outside pull request = green)") + if OUTSIDE_JOB not in ([needs] if isinstance(needs, str) else needs): + problems.append(f"{where}: does not need {OUTSIDE_JOB}") + job_steps = job.get("steps") or [] + for step in job_steps: + uses = str(step.get("uses", "")) + if uses and not uses.startswith("actions/download-artifact@"): + problems.append(f"{where}: uses {uses}") + if re.search(r"\bgit\b|\.github/scripts|\bnode\b|\bbash\b", str(step.get("run", ""))): + problems.append(f"{where}: a step runs git or a script from the checkout") + first = job_steps[0] if job_steps else {} + if RESULT_REFUSAL not in str(first.get("run", "")) or "if" in first or \ + expression((first.get("env") or {}).get("OUTSIDE_PR")) != OUTSIDE_IF: + problems.append(f"{where}: its first step does not refuse an outside pull request (OUTSIDE_PR)") + return problems + + +def pr_guard_problems(name, wf): + events = triggers(wf) + problems = [f"{name}: trigger {e} is not allowed (only {', '.join(sorted(ALLOWED_EVENTS))})" + for e in sorted(events - ALLOWED_EVENTS)] + if events & PR_EVENTS: + jobs = wf.get("jobs") or {} + problems += outside_job_problems(name, jobs.get(OUTSIDE_JOB)) + if name in RESULT_JOBS: + problems += result_job_problems(name, jobs.get(RESULT_JOBS[name])) + problems += [f"{name}: job {j} does not start with the pull request guard" + for j, job in jobs.items() + if j not in {OUTSIDE_JOB, RESULT_JOBS.get(name)} and not guarded(job.get("if"))] + return problems + + +real = fork_workflows() +check("pr-guard: there are fork workflows to check, two of them started by pull requests", + len([n for n, wf in real.items() if triggers(wf) & PR_EVENTS]) >= 2, ", ".join(real)) +problems = [p for n, wf in real.items() for p in pr_guard_problems(n, wf)] +check("pr-guard: every job a pull request can start runs only for this repository's branches; an outside " + "pull request gets only outside_pr (and fork_ci_result), which fail", not problems, "; ".join(problems)) +check("pr-guard: the guard compares the head repository with this one (null when it is gone, so no run)", + "github.event.pull_request.head.repo.full_name == github.repository" in PR_GUARD) + + +def mutant_flagged(name, mutate): + wf = copy.deepcopy(real[name]) + try: + mutate(wf) + except (KeyError, ValueError, IndexError): # nothing to break: the shape is missing, a FAIL above + return False + return bool(pr_guard_problems(name, wf)) + + +def set_if(job, value): + return lambda wf: wf["jobs"][job].__setitem__("if", value) + + +tests_jobs = real["fork-tests.yml"]["jobs"] +mutants = { + "a job without the guard": lambda wf: wf["jobs"]["lint"].pop("if", None), + "a new job without an if": lambda wf: wf["jobs"].__setitem__("extra", {"runs-on": "ubuntu-24.04"}), + "always() || guard": set_if("fork_ci_result", "${{ always() || " + PR_GUARD + " }}"), + "guard || always()": set_if("fork_ci_result", "${{ " + PR_GUARD + " || always() }}"), + "guard && (a) || (b)": set_if("test", "${{ " + PR_GUARD + " && (always()) || (true) }}"), + "fork_ci_result skipped on outside pull requests (guard && (always()))": + set_if("fork_ci_result", "${{ " + PR_GUARD + " && (always()) }}"), + "fork_ci_result not needing outside_pr": lambda wf: wf["jobs"]["fork_ci_result"]["needs"].remove("outside_pr"), + "fork_ci_result without its outside refusal": lambda wf: wf["jobs"]["fork_ci_result"]["steps"][0].__setitem__( + "run", wf["jobs"]["fork_ci_result"]["steps"][0]["run"].replace(RESULT_REFUSAL, "")), + "fork_ci_result checking out": lambda wf: wf["jobs"]["fork_ci_result"]["steps"].insert( + 1, {"uses": "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803"}), + "no outside_pr job": lambda wf: wf["jobs"].pop("outside_pr"), + "outside_pr exiting 0": lambda wf: wf["jobs"]["outside_pr"]["steps"][0].__setitem__( + "run", OUTSIDE_RUN.replace("exit 1", "exit 0")), + "outside_pr with continue-on-error": lambda wf: wf["jobs"]["outside_pr"]["steps"][0].__setitem__( + "continue-on-error", True), + "outside_pr checking out": lambda wf: wf["jobs"]["outside_pr"]["steps"].insert( + 0, {"uses": "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803"}), + "outside_pr with a token": lambda wf: wf["jobs"]["outside_pr"].__setitem__("permissions", {"contents": "read"}), + "outside_pr with a secret in env": lambda wf: wf["jobs"]["outside_pr"].__setitem__("env", {"T": "${{ secrets.X }}"}), + "outside_pr on own branches (== for !=)": set_if("outside_pr", "${{ " + OUTSIDE_IF.replace("!=", "==") + " }}"), + "outside_pr with always()": set_if("outside_pr", "${{ always() }}"), + "guard without its parentheses": set_if("lint", "${{ " + PR_GUARD[1:-1] + " && (true) }}"), + "!= instead of ==": set_if("lint", "${{ " + PR_GUARD.replace("==", "!=") + " }}"), + "the guard in a step only": lambda wf: (wf["jobs"]["changes"].pop("if", None), + wf["jobs"]["changes"]["steps"][0].__setitem__("if", PR_GUARD)), +} +for label, mutate in mutants.items(): + check(f"pr-guard: fork-tests.yml with {label} fails", mutant_flagged("fork-tests.yml", mutate)) +check("pr-guard: fork-checks.yml without the guard fails", + mutant_flagged("fork-checks.yml", lambda wf: wf["jobs"]["no_schema_changes"].pop("if", None))) +check("pr-guard: fork-checks.yml without outside_pr fails", + mutant_flagged("fork-checks.yml", lambda wf: wf["jobs"].pop("outside_pr"))) +check("pr-guard: outside_pr's if is the guard's exact negation", + OUTSIDE_IF == PR_GUARD[2:-1].replace(" || ", " && ").replace("==", "!=")) + +# fork_ci_result's own script, run as Actions runs it, on the results of each kind of run. +result_job = real["fork-tests.yml"]["jobs"].get("fork_ci_result", {}) +result_step = next((s for s in result_job.get("steps") or [] if s.get("name") == "Require every job to pass"), {}) +GATES = ["typecheck", "build", "desktop_launch", "test", "test_packages", "test_web", "test_server", "rust", + "release_smoke", "mobile_native"] + + +def result_run(outside_pr, outside_result, others): + needs = result_job.get("needs") or [] + results = {j: {"result": others, "outputs": {}} for j in needs} + if "changes" in results and others == "success": + results["changes"]["outputs"] = {g: "true" for g in GATES} + results[OUTSIDE_JOB] = {"result": outside_result, "outputs": {}} + with tempfile.NamedTemporaryFile(prefix="fork-contracts-summary-") as summary: + return subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", str(result_step.get("run", "exit 0"))], + env={"PATH": os.environ["PATH"], "RESULTS": json.dumps(results), "RELEASE_RUN": "false", + "OUTSIDE_PR": outside_pr, "GITHUB_STEP_SUMMARY": summary.name}, + capture_output=True, text=True) + + +for label, (outside_pr, outside_result, others, ok) in { + "an outside pull request (outside_pr failed, the rest skipped) fails": ("true", "failure", "skipped", False), + "an outside pull request fails even when every other job succeeded": ("true", "skipped", "success", False), + "outside_pr failed on a run that is no outside pull request fails": ("false", "failure", "success", False), + "an own-branch pull request with every job passed passes": ("false", "skipped", "success", True), + "a push (OUTSIDE_PR empty) with every job passed passes": ("", "skipped", "success", True), +}.items(): + r = result_run(outside_pr, outside_result, others) + check(f"pr-guard: fork_ci_result's script: {label}", + (r.returncode == 0) == ok and (outside_pr != "true" or "outside PR: not tested here" in r.stdout), + f"exit {r.returncode}: {r.stdout.strip()[-300:]} {r.stderr.strip()[-300:]}") +for event in ["issue_comment", "workflow_run", "pull_request_review", "issues"]: + check(f"pr-guard: a fork workflow triggered by {event} fails", + mutant_flagged("fork-release.yml", lambda wf, e=event: wf[True].__setitem__(e, None))) +check("pr-guard: fork-release.yml given a pull_request trigger fails (its jobs have no guard)", + mutant_flagged("fork-release.yml", lambda wf: wf[True].__setitem__("pull_request", None))) +check("pr-guard: the guard alone, and the guard && (one group), pass", + guarded("${{ " + PR_GUARD + " }}") and guarded(PR_GUARD + " && (a == 'x)' || b)") + and not guarded(PR_GUARD + " && (a) && (b)")) + +# --- checkout ---------------------------------------------------------------------------------- +# fork-checks.yml has its own step: it fetches the default branch, base and head by SHA. +OWN_CHECKOUT = {"fork-checks.yml"} + + +def checkout_steps(wfs): + for name, wf in wfs.items(): + for job_id, step in steps(wf): + if name not in OWN_CHECKOUT and "git init" in str(step.get("run", "")): + yield name, job_id, step + + +def checkout_problems(wfs): + problems = [] + for name, wf in wfs.items(): + for job_id, step in steps(wf): + if str(step.get("uses", "")).startswith("actions/checkout@") and \ + str((step.get("with") or {}).get("persist-credentials", "true")).lower() == "false": + problems.append(f"{name}/{job_id}: actions/checkout with persist-credentials: false") + shared = list(checkout_steps(wfs)) + if not shared: + return problems + ["no plain-git checkout step found"] + first = shared[0][2] + for name, job_id, step in shared: + env = step.get("env") or {} + same = {k: step.get(k) for k in ("name", "shell", "run")} == {k: first.get(k) for k in ("name", "shell", "run")} + if not same or set(step) != {"name", "shell", "env", "run"} or set(env) != {"REF", "HISTORY", "SPARSE"} \ + or env.get("HISTORY") not in ("full", "tip"): + problems.append(f"{name}/{job_id}: not the shared checkout step") + return problems + + +check("checkout: no actions/checkout drops credentials, every plain-git checkout is the shared step", + not checkout_problems(real), "; ".join(checkout_problems(real))) +shared = list(checkout_steps(real)) +release_jobs = {j for n, j, _ in shared if n == "fork-release.yml"} +follow_jobs = {j for n, j, _ in shared if n == "fork-follow.yml"} +check("checkout: fork-release.yml's preflight, build_bundle, assemble and publish use it", + release_jobs == {"preflight", "build_bundle", "assemble", "publish"}, ", ".join(sorted(release_jobs))) +check("checkout: fork-follow.yml's guard, plan, advance and report use it", + follow_jobs == {"guard", "plan", "advance", "report"}, ", ".join(sorted(follow_jobs))) + + +def checkout_mutant(mutate): + wfs = copy.deepcopy(real) + try: + mutate(wfs) + except StopIteration: # no shared step to break: the check above has already failed + return False + return bool(checkout_problems(wfs)) + + +def first_step(wfs, name, job): + return next(s for s in wfs[name]["jobs"][job]["steps"] if "git init" in str(s.get("run", ""))) + + +check("checkout: actions/checkout with persist-credentials: false fails", + checkout_mutant(lambda w: w["fork-release.yml"]["jobs"]["publish"]["steps"].insert(0, { + "uses": "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803", + "with": {"persist-credentials": False}}))) +check("checkout: a copy that drifts from the shared step fails", + checkout_mutant(lambda w: first_step(w, "fork-follow.yml", "advance").__setitem__( + "run", first_step(w, "fork-follow.yml", "advance")["run"].replace(" --no-recurse-submodules", "")))) +check("checkout: a copy with a token in its env fails", + checkout_mutant(lambda w: first_step(w, "fork-release.yml", "assemble")["env"].__setitem__( + "GH_TOKEN", "${{ github.token }}"))) + +# End to end: the shared step as Actions runs it (bash -e -o pipefail), against a local origin +# whose tree has gitlinks under .repos/ and no .gitmodules, like upstream's. +work = pathlib.Path(tempfile.mkdtemp(prefix="fork-contracts-")) +stub = work / "bin" +stub.mkdir() +(stub / "gh").write_text("#!/bin/sh\necho 'fork-contracts.test.py: no gh here' >&2\nexit 97\n") +(stub / "gh").chmod(0o755) +base_env = {k: v for k, v in os.environ.items() + if k not in {"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "GITHUB_ACTIONS"}} +base_env.update(PATH=f"{stub}:{os.environ['PATH']}", GH_HOST="gh-stub.invalid", GIT_CONFIG_NOSYSTEM="1", + GIT_CONFIG_GLOBAL=str(work / "gitconfig"), GIT_TERMINAL_PROMPT="0", + GIT_AUTHOR_NAME="test", GIT_AUTHOR_EMAIL="test@example.invalid", + GIT_COMMITTER_NAME="test", GIT_COMMITTER_EMAIL="test@example.invalid") + + +def git(cwd, *args, ok=True): + r = subprocess.run(["git", *args], cwd=cwd, env=base_env, capture_output=True, text=True) + if ok and r.returncode: + raise RuntimeError(f"git {' '.join(args)}: {r.stderr}") + return r.stdout.strip() if ok else r + + +origin = work / "gh" / "me" / "t3code" +origin.mkdir(parents=True) +git(origin, "init", "-q", "-b", "main") +git(origin, "config", "uploadpack.allowFilter", "true") +git(origin, "config", "uploadpack.allowAnySHA1InWant", "true") +for rel, text in {"package.json": "{}\n", ".github/scripts/fork-x.sh": "echo x\n", + ".repos/alchemy-effect/README.md": "vendored\n"}.items(): + (origin / rel).parent.mkdir(parents=True, exist_ok=True) + (origin / rel).write_text(text) +git(origin, "add", "-A") +git(origin, "commit", "-q", "-m", "first") +first_sha = git(origin, "rev-parse", "HEAD") +git(origin, "update-index", "--add", "--cacheinfo", f"160000,{first_sha},.repos/alchemy-effect/submodules/distilled") +git(origin, "commit", "-q", "-m", "gitlink, no .gitmodules") +main_sha = git(origin, "rev-parse", "HEAD") +# A tag named like the branch, on the older commit: a bare `git fetch origin main` takes the tag. +git(origin, "tag", "main", first_sha) +script = shared[0][2]["run"] if shared else "exit 1" + + +def run_step(env, label): + dest = work / f"runner-{label}" + dest.mkdir() + r = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], cwd=dest, + env={**base_env, "GITHUB_SERVER_URL": f"file://{work}/gh", "GITHUB_REPOSITORY": "me/t3code", **env}, + capture_output=True, text=True) + return dest, r + + +# Each job's own REF/HISTORY/SPARSE, with the REF expression standing for a branch or a SHA. +branch_refs = {"${{ inputs.ref }}", "${{ env.SERIES_BRANCH }}"} +for name, job_id, step in shared: + env = dict(step["env"]) + label = f"{name}/{job_id}" + env["REF"] = "main" if env["REF"] in branch_refs else main_sha + dest, r = run_step(env, label.replace("/", "-")) + head = git(dest, "rev-parse", "HEAD") if r.returncode == 0 else "" + sparse = env["SPARSE"].split() + present = {p: (dest / p).exists() for p in ["package.json", ".github/scripts/fork-x.sh", ".repos/alchemy-effect/README.md"]} + if not sparse: + want = {p: True for p in present} + elif sparse == ["/*", "!/.repos/"]: + want = {"package.json": True, ".github/scripts/fork-x.sh": True, ".repos/alchemy-effect/README.md": False} + else: + want = {"package.json": False, ".github/scripts/fork-x.sh": True, ".repos/alchemy-effect/README.md": False} + config = (dest / ".git/config").read_text() if r.returncode == 0 else "" + shallow = (dest / ".git/shallow").exists() + check(f"checkout e2e {label}: checks out the commit ({env['HISTORY']}, sparse {env['SPARSE'] or 'none'}) " + "despite the gitlink, files as asked, no credential in .git", + r.returncode == 0 and head == main_sha and present == want + and shallow == (env["HISTORY"] == "tip") + and not re.search(r"extraheader|token|password|@", config, re.I) + and git(dest, "symbolic-ref", "-q", "HEAD", ok=False).returncode != 0, + f"exit {r.returncode}: {r.stderr.strip()} head={head} files={present}") + +for history in ["tip", "full"]: + dest, r = run_step({"REF": "main", "HISTORY": history, "SPARSE": ""}, f"tag-{history}") + head = git(dest, "rev-parse", "HEAD") if r.returncode == 0 else "" + check(f"checkout e2e: a branch name takes the branch, never a tag of the same name ({history})", + r.returncode == 0 and head == main_sha, f"exit {r.returncode}: {r.stderr.strip()} head={head}") +_, r = run_step({"REF": "refs/tags/main", "HISTORY": "tip", "SPARSE": ""}, "tag-ref") +check("checkout e2e: a tag's full ref fails the step (a branch name or a 40-hex SHA only)", r.returncode != 0) +full, r = run_step({"REF": main_sha, "HISTORY": "full", "SPARSE": ""}, "control") +check("checkout e2e: full history reaches the first commit (blobs on demand)", + r.returncode == 0 and git(full, "rev-list", "--count", "HEAD") == "2") +# The fixture reproduces the failure: this is the command actions/checkout's cleanup runs. +foreach = git(full, "submodule", "foreach", "--recursive", "true", ok=False) +check("checkout e2e: the fixture breaks `git submodule foreach` as upstream's tree does", + foreach.returncode != 0 and "No url found for submodule path" in foreach.stderr, foreach.stderr.strip()) +for label, env in { + "an unknown SHA": {"REF": "1" * 40, "HISTORY": "full", "SPARSE": ""}, + "an unknown branch": {"REF": "gone", "HISTORY": "tip", "SPARSE": ""}, + "an empty REF": {"REF": "", "HISTORY": "full", "SPARSE": ""}, + "an unknown HISTORY": {"REF": main_sha, "HISTORY": "all", "SPARSE": ""}, +}.items(): + _, r = run_step(env, re.sub(r"\W", "-", label)) + check(f"checkout e2e: {label} fails the step", r.returncode != 0) +# A re-run of the step in a workspace that already holds a checkout fails (never mixes two). +leftover, r = run_step({"REF": main_sha, "HISTORY": "tip", "SPARSE": ""}, "leftover") +again = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], cwd=leftover, + env={**base_env, "GITHUB_SERVER_URL": f"file://{work}/gh", "GITHUB_REPOSITORY": "me/t3code", + "REF": main_sha, "HISTORY": "tip", "SPARSE": ""}, capture_output=True, text=True) +check("checkout e2e: a workspace that already holds a checkout fails (never mixes two)", + r.returncode == 0 and again.returncode != 0) + +# --- refusals ---------------------------------------------------------------------------------- +follow = real["fork-follow.yml"] +SCRIPT_LINE = re.compile(r'bash \.github/scripts/[\w.-]+\.sh(?: (?:"\$[A-Z_]+"|--?[a-z-]+|[a-z-]+))*') +SWALLOW = re.compile(r"\|\||(^|;|&)\s*(true|:)\s*$|set \+[a-zA-Z]*e|set \+o|\btrap\b|\bexit 0\b|&\s*$|^\s*!", + re.M) +SHELL_ENV = {"BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS"} + + +def refusal_steps(wf): + """Script steps outside the Report job (whose last sweep may only warn).""" + for job_id, step in steps(wf): + if job_id != "report" and "bash .github/scripts/" in str(step.get("run", "")): + yield job_id, step + + +def is_refusal(step): + """The guard, the Environment re-check and the sweep before the move.""" + return "fork-follow-guard.sh" in str(step.get("run", "")) or \ + (step.get("env") or {}).get("REQUIRE_NEW_OFF") == "true" + + +# Whitelist for a refusal step: these keys, and its run is the optional report folder, then exactly +# one script call. No if, continue-on-error, shell, working-directory: the runner's default +# `bash -e {0}` runs it. +REFUSAL_KEYS = {"id", "name", "env", "run"} +REFUSAL_PRE = {'mkdir -p "$RUNNER_TEMP/reports"'} +# What may run before a refusal in its job: the shared checkout (the checkout rule holds it to one +# text), another refusal step, or one line that writes a step output. +OUTPUT_LINE = re.compile(r'echo "\w+=(?:\$[A-Z_]+|\$\(date -u \+[%A-Za-z:-]+\))" >> "\$GITHUB_OUTPUT"') +SHELL_CHANGE = re.compile(r"GITHUB_ENV|GITHUB_PATH|BASH_ENV|BASH_FUNC_|^\s*(?:function\s+\S+|[^\s=()$]+\s*\(\s*\))", + re.M) + + +def shell_env_keys(env): + return {k for k in env or {} if k in SHELL_ENV or str(k).startswith("BASH_FUNC_")} + + +def earlier_step_problem(step, shared_run): + run = str(step.get("run", "")) + if "uses" in step: + return f"an action ({step['uses']}) can write GITHUB_ENV" + if SHELL_CHANGE.search(run): + return "writes GITHUB_ENV/GITHUB_PATH/BASH_ENV or defines a shell function" + if run == shared_run or is_refusal(step): + return None + if not set(step) <= {"id", "name", "run"} or not OUTPUT_LINE.fullmatch(run.strip()): + return "is not the shared checkout, a refusal or one step-output line" + return None + + +def refusal_problems(wf): + problems = [] + if "defaults" in wf: + problems.append("workflow defaults:") + for scope, env in [("workflow", wf.get("env"))] + [ + (j, job.get("env")) for j, job in wf["jobs"].items()] + [ + (f"{j}/{s.get('name')}", s.get("env")) for j, s in steps(wf)]: + problems += [f"{scope}: env {k}" for k in shell_env_keys(env)] + for job_id, job in wf["jobs"].items(): + if job_id != "report" and ("continue-on-error" in job or "defaults" in job): + problems.append(f"{job_id}: continue-on-error or defaults on the job") + shared_run = shared[0][2]["run"] if shared else None + for job_id, step in refusal_steps(wf): + where = f"{job_id}/{step.get('name')}" + lines = [l.strip() for l in str(step["run"]).strip().splitlines() if l.strip()] + if not is_refusal(step): + # Other script steps: a blacklist, they may hold more than the call. + calls = [l for l in lines if "bash .github/scripts/" in l] + if "continue-on-error" in step: + problems.append(f"{where}: continue-on-error") + if step.get("shell", "bash") != "bash": + problems.append(f"{where}: shell {step.get('shell')}") + if not calls or any(not SCRIPT_LINE.fullmatch(c) for c in calls) or lines[-1] != calls[-1]: + problems.append(f"{where}: the script call is not a plain last line") + if SWALLOW.search(str(step["run"])): + problems.append(f"{where}: a swallowing construct") + continue + # A refusal step: only what the whitelist names. + if not set(step) <= REFUSAL_KEYS: + problems.append(f"{where}: keys {', '.join(sorted(set(step) - REFUSAL_KEYS))} on a refusal step") + body = lines[1:] if lines[:1] and lines[0] in REFUSAL_PRE else lines + if len(body) != 1 or not SCRIPT_LINE.fullmatch(body[0]): + problems.append(f"{where}: the run is more than the report folder and one script call") + job_steps = wf["jobs"][job_id]["steps"] + for earlier in job_steps[:next(i for i, s in enumerate(job_steps) if s is step)]: + why = earlier_step_problem(earlier, shared_run) + if why: + problems.append(f"{where}: an earlier step ({earlier.get('name') or earlier.get('id')}) {why}") + return problems + + +check("refusals: no refusal in fork-follow.yml can be swallowed", not refusal_problems(follow), + "; ".join(refusal_problems(follow))) +refusal_ids = [(j, s.get("name")) for j, s in refusal_steps(follow) if is_refusal(s)] +check("refusals: the guard, the Environment re-check and the sweep before the move are found", + len(refusal_ids) == 3 and {j for j, _ in refusal_ids} == {"guard", "advance"}, str(refusal_ids)) + + +def refusal_mutant(index, mutate): + wf = copy.deepcopy(follow) + job_id, name = refusal_ids[index] + step = next(s for s in wf["jobs"][job_id]["steps"] if s.get("name") == name) + mutate(wf, job_id, step) + return bool(refusal_problems(wf)) + + +def tail(text): + return lambda wf, j, s: s.__setitem__("run", s["run"].rstrip("\n") + text + "\n") + + +swallowers = { + "|| true": tail(" || true"), + "|| :": tail(" || :"), + "|| exit 0": tail(" || exit 0"), + "; true": tail("; true"), + "a trailing `true` line": tail("\ntrue"), + "& (background)": tail(" &"), + "set +e first": lambda wf, j, s: s.__setitem__("run", "set +e\n" + s["run"]), + "set +o errexit first": lambda wf, j, s: s.__setitem__("run", "set +o errexit\n" + s["run"]), + "a trap": lambda wf, j, s: s.__setitem__("run", "trap 'exit 0' EXIT\n" + s["run"]), + "if ...; then": lambda wf, j, s: s.__setitem__("run", "if " + s["run"].strip() + "; then :; fi\n"), + "! negation": lambda wf, j, s: s.__setitem__("run", s["run"].replace("bash .github", "! bash .github")), + "step continue-on-error": lambda wf, j, s: s.__setitem__("continue-on-error", True), + "job continue-on-error": lambda wf, j, s: wf["jobs"][j].__setitem__("continue-on-error", True), + "a step if:": lambda wf, j, s: s.__setitem__("if", "false"), + "shell: sh": lambda wf, j, s: s.__setitem__("shell", "sh -c 'bash {0} || true'"), + "BASH_ENV on the step": lambda wf, j, s: s.setdefault("env", {}).__setitem__("BASH_ENV", "x"), + "BASH_ENV on the job": lambda wf, j, s: wf["jobs"][j].setdefault("env", {}).__setitem__("BASH_ENV", "x"), + "BASH_ENV on the workflow": lambda wf, j, s: wf["env"].__setitem__("BASH_ENV", "x"), + "defaults on the job": lambda wf, j, s: wf["jobs"][j].__setitem__("defaults", {"run": {"shell": "sh"}}), + "defaults on the workflow": lambda wf, j, s: wf.__setitem__("defaults", {"run": {"shell": "sh"}}), + "a bare exit before the call": lambda wf, j, s: s.__setitem__("run", "exit\n" + s["run"]), + "a bash() function before the call": lambda wf, j, s: s.__setitem__("run", "bash() { echo skip; }\n" + s["run"]), + "the call inside if false; then ... fi": lambda wf, j, s: s.__setitem__( + "run", "if false; then\n" + s["run"] + "fi\n"), + "a second call after the script": tail("\nbash .github/scripts/fork-workflows.sh"), + "working-directory on the step": lambda wf, j, s: s.__setitem__("working-directory", "/tmp"), + "BASH_FUNC_bash%% in the step env": lambda wf, j, s: s.setdefault("env", {}).__setitem__( + "BASH_FUNC_bash%%", "() { :; }"), + "an earlier step writing BASH_ENV to $GITHUB_ENV": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": 'echo "BASH_ENV=/tmp/skip" >> "$GITHUB_ENV"'}), + "an earlier step writing $GITHUB_PATH": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": 'echo /tmp/fake-bin >> "$GITHUB_PATH"'}), + "an earlier step defining a function": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": "function bash { :; }\nexport -f bash"}), + "an earlier action": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"uses": "some/action@0000000000000000000000000000000000000000"}), + "an earlier step that runs anything else": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": 'f=GITHUB_; echo "BASH_EN""V=/tmp/x" >> "${!f}EN""V"'}), +} +for index, (job_id, name) in enumerate(refusal_ids): + missed = [label for label, mutate in swallowers.items() if not refusal_mutant(index, mutate)] + check(f"refusals: every way to swallow {job_id}/{name} fails the check", not missed, ", ".join(missed)) + +# --- gh-guard ---------------------------------------------------------------------------------- +GUARD_LINE = '. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh"' +GH_SELF = {"fork-gh-guard.sh", "fork-test-gh.sh"} +# A gh command: the word gh followed by an argument. "\`gh ...\`" inside a message is text. +GH_CALL = re.compile(r"(? calls[0]: + problems.append(f"{name}: calls gh (line {calls[0]}) without sourcing fork-gh-guard.sh first") + continue + before = [l for _, l in code_lines("\n".join(text.splitlines()[:guard[0] - 1])) + if not GUARD_PRELUDE.fullmatch(l.strip())] + if before: + problems.append(f"{name}: {before[0].strip()!r} before the guard line (only #!, set and comments)") + return problems + + +def script_files(): + return {p.name: p.read_text() for p in sorted(scripts_dir.glob("fork-*")) if p.is_file()} + + +files = script_files() +guarded_scripts = sorted(n for n, t in files.items() if n not in GH_SELF and ".test." not in n and gh_calls(n, t)) +check("gh-guard: the scan finds the scripts that call gh", + {"fork-workflows.sh", "fork-follow-guard.sh", "fork-follow-report.sh", "fork-release.sh"} <= set(guarded_scripts), + ", ".join(guarded_scripts)) +check("gh-guard: messages that only name a gh command are not calls", + not gh_calls("fork-follow.sh", files["fork-follow.sh"]) and not gh_calls("fork-follow-push.sh", files["fork-follow-push.sh"])) +check("gh-guard: every fork script that calls gh sources fork-gh-guard.sh first", + not gh_guard_problems(files), "; ".join(gh_guard_problems(files))) +for label, (name, text) in { + "a new script calling gh without the guard": ("fork-new.sh", "#!/usr/bin/env bash\nset -euo pipefail\nx=\"$(gh api user)\"\n"), + "the guard sourced after the first call": ("fork-new.sh", "gh api user\n" + GUARD_LINE + "\n"), + "the guard sourced inside a function": ("fork-new.sh", "f() {\n " + GUARD_LINE + "\n}\n| gh api x\n"), + "a node script spawning gh": ("fork-new.cjs", 'require("node:child_process").execFileSync("gh", ["api", "x"]);\n'), + "a python script running gh": ("fork-new.py", "subprocess.run(['gh', 'api', 'x'])\n"), + "gh after a pipe": ("fork-new.sh", "echo x | gh api x --input -\n"), + "gh in a command substitution": ("fork-new.sh", 'echo "$(gh api x)"\n'), + "gh split by a line continuation": ("fork-new.sh", "x=\"$(gh \\\n api user)\"\n"), + "gh after a continued line": ("fork-new.sh", "echo a \\\n | gh api x --input -\n" + GUARD_LINE + "\n"), + "the guard inside if false; then ... fi": ("fork-new.sh", "set -euo pipefail\nif false; then\n" + GUARD_LINE + + "\nfi\ngh api user\n"), + "the guard after an exit 0 at the top": ("fork-new.sh", "exit 0\n" + GUARD_LINE + "\ngh api user\n"), +}.items(): + check(f"gh-guard: {label} fails", bool(gh_guard_problems({name: text}))) +check("gh-guard: a script that only mentions gh in a message passes", + not gh_guard_problems({"fork-new.sh": 'echo "start it (\\`gh workflow run x.yml\\`)"\n'})) + +# Shell tests: nothing runs before the stub is up but these set-up lines. +SETUP_LINES = [re.compile(p) for p in [ + r"#!/usr/bin/env bash", r"set -[a-z]+( pipefail)?", r'here="\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)" && pwd\)"', + r'root="\$\(mktemp -d\)"', r"""trap 'rm -rf "\$root"' EXIT""", r'\. "\$here/fork-test-gh\.sh"', r'mkdir -p( "\$root/[\w/-]+")+', +]] +STUB_CALL = re.compile(r'fork_test_gh "\$root/[\w/-]+" <<-? ?\'?GH\'?') +SPAWN = re.compile(r"spawnSync|execFileSync|execSync|\bspawn\(|\bexec\(|subprocess|os\.system|Popen") + + +def test_problems(files): + problems = [] + gh_scripts = [n for n, t in files.items() if n not in GH_SELF and ".test." not in n and gh_calls(n, t)] + for name, text in sorted(files.items()): + # This test runs only git and the checkout step, with its own refusing gh first on PATH. + if ".test." not in name or name == pathlib.Path(__file__).name: + continue + if name.endswith(".sh"): + before = [] + for line in text.splitlines(): + if STUB_CALL.fullmatch(line): + break + if line.strip() and not line.startswith("#") and not any(p.fullmatch(line) for p in SETUP_LINES): + before.append(line) + else: + problems.append(f"{name}: never calls fork_test_gh at the top level") + continue + if before: + problems.append(f"{name}: runs {before[0]!r} before the gh stub is set up") + else: + code = [l for _, l in code_lines(text)] + if any(GH_STRING.search(l) for l in code if SPAWN.search(l)) or any( + SPAWN.search(l) and any(s in l for s in gh_scripts) for l in code): + problems.append(f"{name}: runs gh or a script that calls gh; only a .sh test with the stub may") + return problems + + +check("gh-guard: every fork shell test sets up the gh stub before anything else; no other test runs gh", + not test_problems(files), "; ".join(test_problems(files))) +check("gh-guard: the shell tests found include the three that use the stub", + {"fork-follow.test.sh", "fork-release.test.sh", "fork-secret-scan.test.sh"} + <= {n for n in files if n.endswith(".test.sh")}) +stub_head = "#!/usr/bin/env bash\nset -euo pipefail\nhere=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n" +for label, text in { + "no stub at all": stub_head + "bash \"$here/fork-workflows.sh\"\n", + "a git command before the stub": stub_head + "git init -q\nroot=\"$(mktemp -d)\"\n. \"$here/fork-test-gh.sh\"\nfork_test_gh \"$root/bin\" <<'GH'\nexit 1\nGH\n", + "a script run before the stub": stub_head + "root=\"$(mktemp -d)\"\nbash \"$here/fork-release.sh\" resolve\nfork_test_gh \"$root/bin\" <<'GH'\nGH\n", + "the stub only inside a function": stub_head + "setup() {\nfork_test_gh \"$root/bin\" <<'GH'\nGH\n}\n", +}.items(): + check(f"gh-guard: a shell test with {label} fails", bool(test_problems({**files, "fork-new.test.sh": text}))) +check("gh-guard: a node test spawning a gh script fails", + bool(test_problems({**files, "fork-new.test.cjs": 'spawnSync("bash", [".github/scripts/fork-release.sh", "resolve"]);\n'}))) +check("gh-guard: a node test spawning gh fails", + bool(test_problems({**files, "fork-new.test.cjs": 'execFileSync("gh", ["api", "x"]);\n'}))) + +shutil.rmtree(work, ignore_errors=True) +if failures: + print(f"{len(failures)} failed") + sys.exit(1) +print("all passed") diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index eb5a15abecc2e..22ecce78d4395 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -5,24 +5,9 @@ set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -dir="$(realpath "${FORK_FOLLOW_DIR:-$here}")" -workflow="$(realpath "${FORK_FOLLOW_WORKFLOW:-$here/../workflows/fork-follow.yml}")" root="$(mktemp -d)" trap 'rm -rf "$root"' EXIT -# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the -# github.com URLs the scripts use. -export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" -git config --global user.name test -git config --global user.email test@example.invalid -git config --global init.defaultBranch main -git config --global advice.detachedHead false -git config --global url."$root/gh/".insteadOf https://github.com/ -export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code -unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ - SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ - FORK_PUSH_ENVIRONMENT - # Fake gh, served from $root/api: # gh api [-X M] PATH [--paginate] [--jq EXPR] gh issue list|comment|create|close ... # A file $root/api/fail_ makes every call whose path contains fail. Run listings are @@ -86,6 +71,23 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH + +dir="$(realpath "${FORK_FOLLOW_DIR:-$here}")" +workflow="$(realpath "${FORK_FOLLOW_WORKFLOW:-$here/../workflows/fork-follow.yml}")" + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the scripts use. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ + SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ + FORK_PUSH_ENVIRONMENT + export FAKE_API="$root/api" failures=0 @@ -655,7 +657,10 @@ echo '{"workflows":[{"id":9,"state":"active","path":".github/workflows/fork-test D chmod +x "$root/decoy/gh" grep -vxF "$FORK_GH_STUB_MARKER" "$root/bin/gh" > "$root/nomarker/gh" && chmod +x "$root/nomarker/gh" -gh_scripts=(fork-workflows.sh fork-follow-guard.sh fork-follow-report.sh) +# Every script here that sources the guard (fork-contracts.test.py requires it of each that calls gh). +mapfile -t gh_scripts < <(cd "$dir" && grep -lxF '. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh"' fork-*.sh | grep -v '\.test\.sh$' | sort) +check "gh: the refusal tests cover the scripts that source the guard" \ + eval '(for s in fork-follow-guard.sh fork-follow-report.sh fork-release.sh fork-workflows.sh; do [[ " ${gh_scripts[*]} " == *" $s "* ]] || exit 1; done)' refuses() { # refuses ENV...: every gh script, run with ENV, fails, says why, and calls no gh local s for s in "${gh_scripts[@]}"; do @@ -724,11 +729,8 @@ top_level="$advance_if" while [[ "$top_level" =~ \([^()]*\) ]]; do top_level="${top_level//"${BASH_REMATCH[0]}"/_}"; done check "workflow: advance's if: is a conjunction (no top-level ||) of the plan's success and this attempt's guard" \ eval '[[ -n "$advance_if" && "$top_level" != *"||"* && "$top_level" == *"$attempt_check &&"* && "$top_level" == *"needs.plan.result == '"'"'success'"'"' &&"* ]]' -# A refusal that only warns is no refusal: continue-on-error is allowed in the Report job alone (the -# guard, the Environment re-check and the sweep before the move must stop their job). -report_start="$(grep -n "^ report:$" "$workflow" | cut -d: -f1)" -check "workflow: continue-on-error appears only in the Report job (never on the guard, the re-check or the sweep before the move)" \ - eval '[[ -n "$report_start" ]] && ! awk -v r="$report_start" "/continue-on-error/ && NR < r { bad = 1 } END { exit !bad }" "$workflow"' +# A refusal that only warns is no refusal. fork-contracts.test.py ("refusals") checks every way to +# swallow one (continue-on-error, || true, || :, ; true, set +e, a trap, an if:, another shell, BASH_ENV). check "workflow: the move step runs only when the checks before it passed (no if:)" \ eval 'awk "/- name: Move the series branch/ { s = 1; next } s && /^ - / { exit } s && /^ if:/ { bad = 1 } END { exit bad }" <<< "$advance_job"' check "workflow: action none deletes a leftover candidate branch" \ @@ -740,8 +742,10 @@ token_jobs="$(grep -vF "$presence" "$workflow" | awk '/^ [a-z_-]+:$/ { job = $1 env_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /^ environment: fork-push$/ { print job }' "$workflow" | sort -u)" check "workflow: the jobs that read FORK_PUSH_TOKEN, and only they, run in the Environment fork-push" \ eval '[[ -n "$token_jobs" && "$token_jobs" == "$env_jobs" ]] && grep -qx " FORK_PUSH_ENVIRONMENT: fork-push" "$workflow"' -check "workflow: every checkout drops its credentials" \ - eval '[[ $(grep -c "actions/checkout@" "$workflow") == $(grep -c "persist-credentials: false" "$workflow") ]]' +# actions/checkout fails on upstream's gitlinks without .gitmodules once it cleans up credentials; +# every job uses the shared plain-git step (fork-contracts.test.py runs it end to end). +check "workflow: no job uses actions/checkout; four plain-git checkouts, none with a token" \ + eval '! grep -q "actions/checkout@" "$workflow" && [[ $(grep -c "^ git init -q$" "$workflow") == 4 ]]' check "workflow: every action is pinned by commit" \ eval '! grep -E "^\s*-?\s*uses: [^.][^@]*@" "$workflow" | grep -vqE "@[0-9a-f]{40}( |$)"' check "workflow: the report runs on every run (also action none)" \ diff --git a/.github/scripts/fork-gh-guard.sh b/.github/scripts/fork-gh-guard.sh index cbee67444e3f9..3d6c1c273e27f 100644 --- a/.github/scripts/fork-gh-guard.sh +++ b/.github/scripts/fork-gh-guard.sh @@ -9,7 +9,7 @@ # Anything else refuses: the script exits 1 when it sources this file, before any gh call, and # every later call goes through the gh function below, which checks again (a script may change # PATH). The marker must match FORK_GH_STUB_MARKER in fork-test-gh.sh. Tests: fork-follow.test.sh -# and fork-release.test.sh. +# and fork-release.test.sh; fork-contracts.test.py requires this file of every script calling gh. FORK_GH_STUB_MARKER="# fork-gh-stub: a test stand-in for gh that never reaches GitHub" diff --git a/.github/scripts/fork-release-workflow.test.py b/.github/scripts/fork-release-workflow.test.py index 2ee5be72787cc..d5ca50d91064d 100644 --- a/.github/scripts/fork-release-workflow.test.py +++ b/.github/scripts/fork-release-workflow.test.py @@ -62,9 +62,15 @@ def all_steps(): check("release-desktop.yml gets exactly the secrets it declares", not wrong, ", ".join(wrong)) check("FORK_PUSH_TOKEN appears nowhere", "FORK_PUSH_TOKEN" not in text) -bad = [j for j, s in all_steps() if str(s.get("uses", "")).startswith("actions/checkout@") - and (s.get("with") or {}).get("persist-credentials") is not False] -check("every checkout sets persist-credentials: false", not bad, ", ".join(bad)) +# actions/checkout fails on upstream's gitlinks without .gitmodules once it cleans up credentials; +# every job checks out with the shared plain-git step instead (fork-contracts.test.py runs it). +bad = [j for j, s in all_steps() if str(s.get("uses", "")).startswith("actions/checkout@")] +check("no job uses actions/checkout", not bad, ", ".join(bad)) +plain = {j for j, s in all_steps() if s.get("name") == "Checkout" and "git init" in str(s.get("run", ""))} +check("every job that checks out uses the plain-git step, with no token", + plain == {"preflight", "build_bundle", "assemble", "publish"} + and all("github.token" not in yaml.safe_dump(s) and "secrets." not in yaml.safe_dump(s) + for _, s in all_steps() if s.get("name") == "Checkout"), ", ".join(sorted(plain))) release_steps = [(j, s) for j, s in all_steps() if str(s.get("uses", "")).startswith("softprops/action-gh-release@")] check("one release-action step", len(release_steps) == 1) @@ -132,9 +138,10 @@ def index(pred): check("the former name T3CODE_RELEASE_REPOSITORY appears nowhere", "T3CODE_RELEASE_REPOSITORY" not in text) # The write job's guard script comes from the workflow's own commit, not the commit being built. -guard = [s for s in steps(jobs["publish"]) if str(s.get("uses", "")).startswith("actions/checkout@")] -check("publish checks out its guard script at github.workflow_sha", - len(guard) == 1 and (guard[0].get("with") or {}).get("ref") == "${{ github.workflow_sha }}") +guard = [s for s in steps(jobs["publish"]) if "git init" in str(s.get("run", ""))] +check("publish checks out only its guard scripts, at github.workflow_sha", + len(guard) == 1 and guard[0].get("env") == {"REF": "${{ github.workflow_sha }}", "HISTORY": "tip", + "SPARSE": "/.github/scripts/"}) if failures: print(f"{len(failures)} failed") diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh index aae82c37ded49..0ae10f0da7f01 100755 --- a/.github/scripts/fork-release.test.sh +++ b/.github/scripts/fork-release.test.sh @@ -4,21 +4,9 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -script="$(realpath "${FORK_RELEASE_SH:-$here/fork-release.sh}")" root="$(mktemp -d)" trap 'rm -rf "$root"' EXIT -# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the -# github.com URLs the workflow uses. -export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" -git config --global user.name test -git config --global user.email test@example.invalid -git config --global init.defaultBranch main -git config --global advice.detachedHead false -git config --global url."$root/gh/".insteadOf https://github.com/ -export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code -unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN - # Fake gh: `gh api [-X M] PATH [--paginate] [--jq EXPR] [-f/-F k=v]...` served from $root/api. # fork_test_gh (fork-test-gh.sh) installs it first on PATH, with no token, a .invalid GH_HOST and # FORK_TEST_MODE, so the script refuses to run if any other gh would answer. @@ -57,6 +45,20 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH + +script="$(realpath "${FORK_RELEASE_SH:-$here/fork-release.sh}")" + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the workflow uses. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN + export FAKE_API="$root/api" failures=0 diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs index 4d6e3ebe7b017..a8e7e9d19e871 100644 --- a/.github/scripts/fork-schema-check.test.cjs +++ b/.github/scripts/fork-schema-check.test.cjs @@ -448,22 +448,134 @@ test("any action the workflow uses is pinned by commit SHA", () => { for (const line of uses) assert.match(line, /@[0-9a-f]{40}$/, line); }); -test("the workflow runs the default branch's copy and never runs pull request code", () => { - const workflow = fs.readFileSync(WORKFLOW, "utf8"); +// The shell scripts of a workflow: each `run:` value with every line after it that is indented past +// the `run:` key, blank lines included. That covers a `run: |` or `>` block, a value that starts on +// the next line (`run:` alone) and a plain or quoted scalar continued on later lines; YAML reads all +// of them as one script. +function runScripts(workflow) { + const lines = workflow.split("\n"); + const scripts = []; + for (let i = 0; i < lines.length; i++) { + const m = /^(\s*(?:-\s+)?)run:(?:\s+(.*))?$/.exec(lines[i]); + if (!m) continue; + const head = (m[2] ?? "").trim(); + const body = head === "" || /^[|>][-+0-9]*\s*(#.*)?$/.test(head) ? [] : [head]; + while ( + i + 1 < lines.length && + (lines[i + 1].trim() === "" || lines[i + 1].search(/\S/) > m[1].length) + ) { + body.push(lines[++i]); + } + scripts.push(body.join("\n")); + } + return scripts; +} + +// Why a workflow may run pull request code or hand it a token; empty when it cannot. +function prCodeProblems(workflow) { const code = workflow.replace(/^\s*#.*$/gm, ""); - assert.match(code, /^on:\n {2}pull_request_target:/m); - assert.doesNotMatch(code, /^\s+pull_request:/m); + const problems = []; + const expect = (ok, why) => ok || problems.push(why); + expect(/^on:\n {2}pull_request_target:/m.test(code), "not triggered by pull_request_target"); + expect(!/^\s+pull_request:/m.test(code), "triggered by pull_request"); // The only checkout is of the default branch (`github.sha` under pull_request_target). - assert.doesNotMatch(code, /actions\/checkout|\bref:|secrets\.|GITHUB_TOKEN/); - assert.deepEqual(code.match(/git (checkout|switch|worktree|reset|read-tree)\b.*/g), [ - 'git checkout -q --detach "$DEFAULT_SHA"', + const banned = /actions\/checkout|\bref:|secrets\.|GITHUB_TOKEN|github\.token/.exec(code); + expect(!banned, `uses ${banned?.[0]}`); + const checkouts = code.match(/git (checkout|switch|worktree|reset|read-tree)\b.*/g) ?? []; + expect( + checkouts.length === 1 && checkouts[0] === 'git checkout -q --detach "$DEFAULT_SHA"', + `checks out ${checkouts.join(", ")}`, + ); + expect( + JSON.stringify(code.match(/DEFAULT_SHA:.*/g)) === + JSON.stringify(["DEFAULT_SHA: ${{ github.sha }}"]), + "DEFAULT_SHA is not github.sha", + ); + // Event values reach the shell only through env, never pasted into a script. + for (const script of runScripts(code)) { + expect( + !/\$\{\{/.test(script), + `an expression inside a run script: ${script.trim().split("\n")[0]}`, + ); + } + expect( + /node \.github\/scripts\/fork-schema-check\.cjs "\$BASE_SHA" "\$HEAD_SHA"/.test(code), + "does not run the check on $BASE_SHA $HEAD_SHA", + ); + return problems; +} + +test("the workflow runs the default branch's copy and never runs pull request code", () => { + assert.deepEqual(prCodeProblems(fs.readFileSync(WORKFLOW, "utf8")), []); +}); + +test("runScripts reads one-line runs and whole run blocks", () => { + const scripts = runScripts( + [ + " - run: echo one", + " - name: x", + " run: |", + " a", + "", + " b", + " - run: >-", + " c", + " env: {}", + " - run:", + " d", + " - run: e", + " f", + " name: y", + ].join("\n"), + ); + assert.deepEqual(scripts, [ + "echo one", + " a\n\n b", + " c", + " d", + "e\n f", ]); - assert.deepEqual(code.match(/DEFAULT_SHA:.*/g), ["DEFAULT_SHA: ${{ github.sha }}"]); - // Event values reach the shell only through env, never pasted into the script. - for (const line of code.split("\n").filter((l) => /run:/.test(l))) { - assert.doesNotMatch(line, /\$\{\{/, line); +}); + +test("a workflow that pastes PR values into a script or reaches a token fails the check", () => { + const workflow = fs.readFileSync(WORKFLOW, "utf8"); + // The checkout step's `run: |` block: a line after its first, where a one-line check misses it. + const block = /^ +run: \|\n( +)(\S.*)\n/m; + assert.match(workflow, block); + const mutants = { + "a PR title inside a run block": workflow.replace( + block, + (all, indent) => `${all}${indent}echo "\${{ github.event.pull_request.title }}"\n`, + ), + "a PR head ref on a later line of a run block": workflow.replace( + block, + (all, indent) => `${all}${indent}true\n\n${indent}git log -1 \${{ github.head_ref }}\n`, + ), + "github.token in an env": workflow.replace( + /^( +)env:\n/m, + (line, indent) => `${line}${indent} GH_TOKEN: \${{ github.token }}\n`, + ), + "GITHUB_TOKEN in an env": workflow.replace( + /^( +)env:\n/m, + (line, indent) => `${line}${indent} T: \${{ secrets.GITHUB_TOKEN }}\n`, + ), + "a pull_request trigger": workflow.replace(/^on:\n/m, "on:\n pull_request:\n"), + // `run:` alone, the script on the next line. + "an expression in a run value that starts on the next line": workflow.replace( + /^( +)run: (node --test .*)$/m, + (all, indent, cmd) => + `${indent}run:\n${indent} echo \${{ github.event.pull_request.title }} && ${cmd}`, + ), + // A plain one-line run continued on the next line: YAML folds it into the same script. + "an expression on the continuation of a plain run": workflow.replace( + /^( +)run: (node --test .*)$/m, + (all, indent, cmd) => `${all}\n${indent} \${{ github.head_ref }}`, + ), + }; + for (const [label, text] of Object.entries(mutants)) { + assert.notEqual(text, workflow, label); + assert.notDeepEqual(prCodeProblems(text), [], label); } - assert.match(code, /node \.github\/scripts\/fork-schema-check\.cjs "\$BASE_SHA" "\$HEAD_SHA"/); }); // End to end: the real git in a scratch repository, the way CI calls the check. diff --git a/.github/scripts/fork-secret-scan.test.sh b/.github/scripts/fork-secret-scan.test.sh index f0022f7275e1a..142cacbbaee61 100755 --- a/.github/scripts/fork-secret-scan.test.sh +++ b/.github/scripts/fork-secret-scan.test.sh @@ -6,10 +6,20 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -script="$(realpath "${FORK_SECRET_SCAN_SH:-$here/fork-secret-scan.sh}")" root="$(mktemp -d)" trap 'rm -rf "$root"' EXIT +# No script tested here calls gh. The stub (fork-test-gh.sh: no token, a .invalid GH_HOST) refuses +# every call, so a scan that starts calling gh fails here instead of reaching GitHub. +. "$here/fork-test-gh.sh" +mkdir -p "$root/bin" +fork_test_gh "$root/bin" <<'GH' +echo "fake gh: fork-secret-scan.test.sh expects no gh call (gh $*)" >&2 +exit 97 +GH + +script="$(realpath "${FORK_SECRET_SCAN_SH:-$here/fork-secret-scan.sh}")" + export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" git config --global user.name test git config --global user.email test@example.invalid diff --git a/.github/scripts/fork-test-gh.sh b/.github/scripts/fork-test-gh.sh index 15d59ed594837..cfcb0ad4f6baa 100644 --- a/.github/scripts/fork-test-gh.sh +++ b/.github/scripts/fork-test-gh.sh @@ -1,6 +1,6 @@ # shellcheck shell=bash -# Sourced first by every fork test that runs a script calling gh (fork-follow.test.sh, -# fork-release.test.sh). `fork_test_gh DIR < STUB` cuts the test off from GitHub before any script +# Sourced first by every fork shell test (fork-contracts.test.py requires it of each *.test.sh). +# `fork_test_gh DIR < STUB` cuts the test off from GitHub before any script # runs: no GitHub token, GH_HOST a .invalid host and an empty gh config (so even a real gh has # nothing to reach and no login), and the stub read from stdin as DIR/gh, first on PATH, with the # marker line fork-gh-guard.sh looks for. FORK_TEST_MODE then makes the scripts refuse whenever diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index 46f0e986a2def..c64602abd5cec 100644 --- a/.github/workflows/fork-checks.yml +++ b/.github/workflows/fork-checks.yml @@ -14,9 +14,18 @@ on: permissions: contents: read +# Every job but `outside_pr` starts with the same condition: on a pull request, only one whose branch +# is in this repository. A pull request from another fork runs none of them, not even after approval +# (the repository asks approval only for pull_request runs, and pull_request_target skips that, but +# this file always comes from the default branch). It gets only `outside_pr`, which checks nothing +# out, runs nothing from the pull request, gets no token and fails. A job skipped by its if: counts +# as passed, so a ruleset that requires "No database schema changes" must also require "Outside pull +# request". A head repository GitHub no longer knows reads as "another fork". Test: +# fork-contracts.test.py. jobs: no_schema_changes: name: No database schema changes + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -46,3 +55,15 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/fork-schema-check.cjs "$BASE_SHA" "$HEAD_SHA" + + outside_pr: + name: Outside pull request + if: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + steps: + - name: Refuse an outside pull request + run: | + echo "::error::outside PR: not tested here; a maintainer must review" + exit 1 diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 8be10d65cc509..16df6246985b0 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -81,12 +81,34 @@ jobs: name: Note the start run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - - name: Checkout the scripts - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ github.sha }} + HISTORY: tip + SPARSE: /.github/scripts/ + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Refuse unless only the Environment holds FORK_PUSH_TOKEN, for the series branch only env: @@ -135,13 +157,34 @@ jobs: disabled: ${{ steps.sweep_plan.outputs.disabled }},${{ steps.sweep_push.outputs.disabled }} cancelled: ${{ steps.sweep_push.outputs.cancelled }} steps: - - name: Checkout the series - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ env.SERIES_BRANCH }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ env.SERIES_BRANCH }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Test the follow scripts run: bash .github/scripts/fork-follow.test.sh @@ -270,13 +313,34 @@ jobs: disabled: ${{ steps.sweep.outputs.disabled }} cancelled: ${{ steps.sweep.outputs.cancelled }} steps: - - name: Checkout the scripts - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ needs.plan.outputs.candidate }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ needs.plan.outputs.candidate }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD # The release can take an hour or more, so the Environment's rule is checked again. (The # repository secret cannot be checked from inside the Environment; the Guard job did.) @@ -355,13 +419,34 @@ jobs: contents: read issues: write steps: - - name: Checkout the scripts - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ github.sha }} - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ github.sha }} + HISTORY: tip + SPARSE: /.github/scripts/ + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Download the jobs' reasons continue-on-error: true diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index fb7e08d5ae4d1..d5bc8fb2d93d3 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -95,13 +95,34 @@ jobs: base_tag: ${{ steps.meta.outputs.base_tag }} upstream: ${{ steps.meta.outputs.upstream }} steps: + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + shell: bash + env: + REF: ${{ inputs.ref }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Test the release guards shell: bash @@ -135,15 +156,34 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ needs.preflight.outputs.ref }} - persist-credentials: false - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + REF: ${{ needs.preflight.outputs.ref }} + HISTORY: tip + SPARSE: "/* !/.repos/" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Setup dependencies uses: ./.github/actions/setup-ci-deps @@ -350,13 +390,34 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + shell: bash + env: + REF: ${{ needs.preflight.outputs.ref }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Setup dependencies uses: ./.github/actions/setup-ci-deps @@ -469,13 +530,34 @@ jobs: outputs: published: ${{ steps.publish.outputs.published }} steps: - - name: Checkout the guard script - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ github.workflow_sha }} + HISTORY: tip + SPARSE: /.github/scripts/ + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Download release files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml index 29063b2b940ce..72e5e25e1a1a7 100644 --- a/.github/workflows/fork-tests.yml +++ b/.github/workflows/fork-tests.yml @@ -52,9 +52,36 @@ concurrency: group: fork-tests-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Every job but two starts with the same condition: on a pull request, only one whose branch is in +# this repository. A pull request from another fork gets only `outside_pr`, which checks nothing +# out, runs nothing from the pull request, gets no token and fails, and `fork_ci_result`, which +# needs it and fails too: a job skipped by its if: counts as passed, so a required fork-ci-result +# never goes green on an outside pull request that nothing tested. A head repository GitHub no +# longer knows reads as "another fork". Test: fork-contracts.test.py. +# +# This condition stops only honest pull requests. Under pull_request GitHub runs the pull request's +# own copy of this file, so an outside pull request can delete the condition, or add a workflow of +# its own, and its code runs as soon as a maintainer approves the run. The real protection here is +# the repository's approval setting (approval for every outside contributor): keep it on, and never +# approve an outside pull request's run; push its branch to this repository after review instead. +# (fork-checks.yml is different: pull_request_target always runs main's copy.) jobs: + # Fails on purpose: an outside pull request is not tested here, see above. + outside_pr: + name: Outside pull request + if: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }} + runs-on: ubuntu-24.04 + timeout-minutes: 2 + permissions: {} + steps: + - name: Refuse an outside pull request + run: | + echo "::error::outside PR: not tested here; a maintainer must review" + exit 1 + changes: name: Changes + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -143,6 +170,7 @@ jobs: # upstream changed since the copy was last recorded. workflows: name: Workflow lint and upstream drift + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -191,7 +219,7 @@ jobs: # The ranges, and why: .github/scripts/fork-secret-scan.sh. secrets: name: Secret scan - if: ${{ inputs.ref == '' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (inputs.ref == '') }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -235,6 +263,7 @@ jobs: lint: name: Lint + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -284,7 +313,7 @@ jobs: build: name: Build and Typecheck needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false')) }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -433,7 +462,7 @@ jobs: test: name: Test ${{ matrix.shard }} needs: changes - if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.test != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: @@ -550,7 +579,7 @@ jobs: test_web: name: Test Web ${{ matrix.shard }} needs: changes - if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.test_web != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: @@ -614,6 +643,7 @@ jobs: # release run's resolved commit (seconds; every other job waits for it too). test_server: name: Test Server ${{ matrix.shard }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -750,7 +780,7 @@ jobs: rust: name: Rust needs: changes - if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.rust != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -786,7 +816,7 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis needs: changes - if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.mobile_native != 'false') }} runs-on: macos-26 timeout-minutes: 15 steps: @@ -817,7 +847,7 @@ jobs: release_smoke: name: Release Smoke needs: changes - if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.release_smoke != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -848,9 +878,11 @@ jobs: # branch protection, so a cancelled run must still produce a failing result. fork_ci_result: name: fork-ci-result + # Runs on every pull request, outside ones too (it checks nothing out), so that it fails there. if: ${{ always() }} needs: [ + outside_pr, changes, workflows, secrets, @@ -873,6 +905,7 @@ jobs: env: RESULTS: ${{ toJSON(needs) }} RELEASE_RUN: ${{ inputs.ref != '' }} + OUTSIDE_PR: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }} run: | echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' # A jq program: $release is jq's variable, not the shell's. @@ -886,7 +919,8 @@ jobs: then "release run: upstream release.yml has no macOS lint" else "no native mobile source, Brewfile or CI workflow changed" end), release_smoke: "the change does not affect scripts/", - secrets: "release run: the commit was scanned on its way to main" + secrets: "release run: the commit was scanned on its way to main", + outside_pr: "not a pull request from another fork" }' { echo "### fork-ci-result" @@ -901,16 +935,23 @@ jobs: echo "Server shards passed without running tests: the change affects nothing t3 (apps/server) depends on." fi } >> "$GITHUB_STEP_SUMMARY" + # An outside pull request fails here whatever the other jobs did. + if [[ "$OUTSIDE_PR" == true ]]; then + echo "::error::outside PR: not tested here; a maintainer must review" + exit 1 + fi # A job may skip only when `changes` said every gate it represents was # not needed (gates are keyed by job name, except merged Build and the - # macOS lint), or, for the secret scan, in a release run. + # macOS lint), or, for the secret scan, in a release run. outside_pr + # passes by being skipped (outside pull requests stopped above). echo "$RESULTS" | jq -e --arg release "$RELEASE_RUN" '.changes.outputs as $gates | {build: ["build", "typecheck", "desktop_launch"], mobile_native_static_analysis: ["mobile_native"]} as $renamed | all(to_entries[]; - .value.result == "success" - or (.value.result == "skipped" - and (if .key == "secrets" then $release == "true" - else (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false")) end)))' > /dev/null + if .key == "outside_pr" then .value.result == "skipped" + else .value.result == "success" + or (.value.result == "skipped" + and (if .key == "secrets" then $release == "true" + else (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false")) end)) end)' > /dev/null # A run whose server tests ran must have produced the transfer measurements. - name: Require transfer measurements From f72c8805ef35ed3bc65d39c69e4c31bab7376d4a Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:58:06 +0100 Subject: [PATCH 09/27] fork: delete four outside-started upstream workflows and keep them deleted (#14) * fork: delete four outside-started upstream workflows and keep them deleted The fork's sweep disables upstream workflows only once GitHub lists them, so it cannot stop the first run of one an outside event starts. Owner decision 2026-11-12: delete mobile-eas-preview-fork.yml (pull_request_target), desktop-macos-preview.yml, thread-transfer-report.yml and mobile-fingerprint-label.yml (workflow_run on "CI", a name an outside PR can take). .github/fork-removed-workflows.txt lists them. fork-follow.sh reads it from the series head (missing or malformed: the plan stops), resolves a rebase conflict on listed files only by deleting them, deletes again any listed file upstream brings back, refuses a same-base series that still has one, and refuses a new upstream workflow that outside events can start (or whose triggers fork-workflow-triggers.cjs cannot read) unless the list names it. Co-Authored-By: Claude Opus 5.5 * fork: the trigger reader fails closed on more shapes and has its own tests - fork-workflow-triggers.cjs exports readTriggers and gets fork-workflow-triggers.test.cjs, like the other fork-*.cjs scripts. The test requires it, so knip no longer reports it as an unused file. - The reader refuses: a line break other than \n or \r\n (a lone \r, U+0085, U+2028, U+2029); an unindented line that is not a plain `key:` (`on :`, escaped or quoted keys, `? on`, directives, a root list, a BOM); a quote left open at the end of a line; a bracket list whose later lines are not indented more than its first, or that never closes. Block scalar text is skipped. Indent counts ASCII spaces only. - Outside events add pull_request_review, pull_request_review_comment, gollum, check_run, check_suite, status and deployment_status. - fork-follow.test.sh: plan cases for a CRLF list, the plain-text pull_request_target backstop, the new events and a decoy on: in a multi-line string. - fork-schema-check.test.cjs: drop an unused parameter that vp check reports as an error. --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 1 + .github/fork-removed-workflows.txt | 14 + .github/scripts/fork-contracts.test.py | 43 +++ .github/scripts/fork-follow.sh | 112 +++++- .github/scripts/fork-follow.test.sh | 140 ++++++- .github/scripts/fork-schema-check.test.cjs | 2 +- .github/scripts/fork-workflow-triggers.cjs | 184 +++++++++ .../scripts/fork-workflow-triggers.test.cjs | 197 ++++++++++ .github/workflows/desktop-macos-preview.yml | 108 ------ .github/workflows/fork-follow.yml | 4 + .github/workflows/mobile-eas-preview-fork.yml | 355 ------------------ .../workflows/mobile-fingerprint-label.yml | 189 ---------- .github/workflows/thread-transfer-report.yml | 75 ---- 13 files changed, 692 insertions(+), 732 deletions(-) create mode 100644 .github/fork-removed-workflows.txt create mode 100644 .github/scripts/fork-workflow-triggers.cjs create mode 100644 .github/scripts/fork-workflow-triggers.test.cjs delete mode 100644 .github/workflows/desktop-macos-preview.yml delete mode 100644 .github/workflows/mobile-eas-preview-fork.yml delete mode 100644 .github/workflows/mobile-fingerprint-label.yml delete mode 100644 .github/workflows/thread-transfer-report.yml diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 9947547722dcc..76cefd07c8701 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -14,6 +14,7 @@ .github/scripts/fork-secret-scan.test.sh .github/scripts/fork-test-shards.test.cjs .github/scripts/fork-tests-drift.test.cjs +.github/scripts/fork-workflow-triggers.test.cjs # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/.github/fork-removed-workflows.txt b/.github/fork-removed-workflows.txt new file mode 100644 index 0000000000000..8dbe43ec36eda --- /dev/null +++ b/.github/fork-removed-workflows.txt @@ -0,0 +1,14 @@ +# Upstream workflow files the fork deletes and keeps deleted (fork-follow.sh). One path per line; +# '#' starts a comment line. On every follow the plan deletes each listed file that upstream edited +# or brought back (a rebase conflict on a listed file is resolved by deleting it), and it stops red +# when this file is missing or a line is not a .github/workflows/*.yml path. The fork's sweep +# (fork-workflows.sh) disables upstream workflows only once GitHub lists them, which cannot stop the +# first run of one that someone outside the fork can start; these are such files. +# +# pull_request_target: an outside pull request starts it with main's copy and a write token. +.github/workflows/mobile-eas-preview-fork.yml +# Owner decision, 2026-11-12. +.github/workflows/desktop-macos-preview.yml +# workflow_run on a workflow named "CI": an outside pull request can add a workflow of that name. +.github/workflows/thread-transfer-report.yml +.github/workflows/mobile-fingerprint-label.yml diff --git a/.github/scripts/fork-contracts.test.py b/.github/scripts/fork-contracts.test.py index 639dc818b2c27..e1ef7a69bcb41 100644 --- a/.github/scripts/fork-contracts.test.py +++ b/.github/scripts/fork-contracts.test.py @@ -15,6 +15,9 @@ gh-guard every fork script that calls gh sources fork-gh-guard.sh at the top level, after nothing but `set`, before its first call (backslash continuations joined), and every fork shell test sets up the gh stub (fork-test-gh.sh) before anything else. + removed .github/fork-removed-workflows.txt (the upstream workflows the fork deletes, kept + deleted by fork-follow.sh) is there, every line is an upstream workflow path, none of + them is in the tree, and it names the files the owner decided to delete. python3 .github/scripts/fork-contracts.test.py @@ -750,6 +753,46 @@ def test_problems(files): check("gh-guard: a node test spawning gh fails", bool(test_problems({**files, "fork-new.test.cjs": 'execFileSync("gh", ["api", "x"]);\n'}))) +# --- removed ----------------------------------------------------------------------------------- +removed_path = root / ".github/fork-removed-workflows.txt" +REMOVED_LINE = re.compile(r"\.github/workflows/[^/,\s]+\.ya?ml") +# Owner decision 2026-11-12: outside events start these before the sweep can disable them. +OWNER_REMOVED = {".github/workflows/" + n for n in [ + "mobile-eas-preview-fork.yml", "desktop-macos-preview.yml", "thread-transfer-report.yml", + "mobile-fingerprint-label.yml"]} + + +def removed_entries(text): + return [l.rstrip("\r") for l in text.splitlines() if not re.fullmatch(r"\s*(#.*)?", l.rstrip("\r"))] + + +def removed_problems(text, tree): + problems = [] + for line in removed_entries(text): + if not REMOVED_LINE.fullmatch(line) or line.startswith(".github/workflows/fork-"): + problems.append(f"{line!r} is not an upstream workflow path") + elif line in tree: + problems.append(f"{line} is listed as deleted but is in the tree") + return problems + + +tree = {str(p.relative_to(root)) for p in workflows_dir.iterdir()} +removed_text = removed_path.read_text() if removed_path.is_file() else None +check("removed: .github/fork-removed-workflows.txt is there", removed_text is not None) +removed_text = removed_text or "" +check("removed: every line is an upstream workflow path that is not in the tree", + not removed_problems(removed_text, tree), "; ".join(removed_problems(removed_text, tree))) +check("removed: the list names the four outside-started workflows the owner decided to delete", + OWNER_REMOVED <= set(removed_entries(removed_text)), + ", ".join(sorted(OWNER_REMOVED - set(removed_entries(removed_text))))) +for label, (text, extra) in { + "a listed file still in the tree": (removed_text, {".github/workflows/thread-transfer-report.yml"}), + "a bare name": (removed_text + "ci.yml\n", set()), + "a fork-*.yml": (removed_text + ".github/workflows/fork-follow.yml\n", set()), + "a path outside .github/workflows": (removed_text + ".github/workflows/../ci.yml\n", set()), +}.items(): + check(f"removed: {label} fails", bool(removed_problems(text, tree | extra))) + shutil.rmtree(work, ignore_errors=True) if failures: print(f"{len(failures)} failed") diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index 227eb438fc6d4..637780154199b 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -28,6 +28,14 @@ # new one (','-separated; empty without a new base). The branch move refuses while # one of them is not listed by GitHub yet, so it cannot be disabled before main has # it (a changed file may now run on push). +# +# Removed upstream workflows: .github/fork-removed-workflows.txt (read from the series head) lists +# upstream workflow files the fork deletes. A rebase conflict on listed files only is resolved by +# deleting them; any listed file the rebased candidate still has (upstream re-added it) is deleted +# by one more commit. On the same base the series must not have one. A missing or malformed list +# stops the plan: it never ships the files because it could not read which ones to delete. +# A workflow file the new upstream release adds that someone outside the fork can start (see +# outside_events) and that the list does not delete stops the plan too, naming it. set -Eeuo pipefail : "${UPSTREAM:?owner/name of the upstream repository}" @@ -64,6 +72,7 @@ stable_re='^v[0-9]+\.[0-9]+\.[0-9]+$' fork_re='^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' head="$(git rev-parse HEAD)" run_sha="${RUN_SHA:-$head}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Read-only fetches into refs this run alone uses: upstream's tags and default branch, and this # fork's tags (its releases). @@ -146,6 +155,68 @@ schema_check() { # schema_check BASE WHAT: fails with the check's output, a refu } schema_check "$base" "The series" +# The upstream workflow files the fork deletes, from the series head. Fail closed: without a +# readable list the plan cannot keep them deleted, so it stops instead of shipping them. +removed_file=.github/fork-removed-workflows.txt +removed_re='^\.github/workflows/[^/,[:space:]]+\.ya?ml$' +git cat-file -e "$head:$removed_file" 2> /dev/null \ + || fail "The series has no $removed_file, the list of upstream workflow files the fork deletes." \ + "Without it the plan cannot keep them deleted, so it stopped; the series was left as it is." \ + "Restore the file with a fork patch (an empty list holds only comment lines)." +removed=() +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ "$line" =~ ^[[:space:]]*(#.*)?$ ]] && continue + if [[ ! "$line" =~ $removed_re || "$line" =~ ^\.github/workflows/fork- ]]; then + fail "$removed_file has a line that is not an upstream workflow path (.github/workflows/.yml, not fork-*):" \ + "\`$line\`" "The series was left as it is. Fix the line with a fork patch." + fi + removed+=("$line") +done < <(git show "$head:$removed_file") +is_removed() { local p; for p in "${removed[@]}"; do [[ "$p" != "$1" ]] || return 0; done; return 1; } +present_removed() { # present_removed COMMIT: the listed files COMMIT has, one per line + local p + for p in "${removed[@]}"; do + git cat-file -e "$1:$p" 2> /dev/null && printf '%s\n' "$p" + done + return 0 +} + +# Events someone outside the fork can start a workflow with. GitHub runs such a workflow before +# the sweep can see and disable it, so a new one must not reach the series. Directly: an outside +# pull request (pull_request_target, main's copy), a review or a review comment on a pull request +# from a fork/* branch (pull_request_review, pull_request_review_comment: that branch's copy, with +# the repository's token), an issue or a comment (issues, issue_comment), a discussion +# (discussion, discussion_comment), a star (watch), a fork, a wiki edit if the wiki is open +# (gollum). Indirectly: a workflow of a given name that an outside pull request can add +# (workflow_run), and the checks, commit statuses and deployments that apps post on an outside +# pull request's commits (check_run, check_suite, status, deployment_status). pull_request from +# another fork gets a read-only token and no secrets, so the sweep is enough for it. The rest need +# write access or more. +outside_events='^(pull_request_target|pull_request_review|pull_request_review_comment|issues|issue_comment|discussion|discussion_comment|watch|fork|gollum|workflow_run|check_run|check_suite|status|deployment_status)$' +refuse_outside_workflows() { # refuse_outside_workflows UPSTREAM_BASE UPSTREAM_TARGET TAG CANDIDATE + local path list=() found=() events status + workflow_paths A "$1" "$2" + IFS=, read -r -a list <<< "$paths" + for path in "${list[@]}"; do + [[ "$path" == *.yml || "$path" == *.yaml ]] || continue + git cat-file -e "$4:$path" 2> /dev/null || continue + git show "$4:$path" > "$logs/workflow.yml" + status=0 + events="$(node "$here/fork-workflow-triggers.cjs" "$logs/workflow.yml" 2> "$logs/triggers.log")" || status=$? + if ((status != 0)); then + found+=("- \`$path\`: its triggers could not be read ($(tr '\n' ' ' < "$logs/triggers.log"| sed 's/ *$//'))") + elif grep -qE "$outside_events" <<< "$events" || grep -qwE 'pull_request_target|workflow_run' "$logs/workflow.yml"; then + found+=("- \`$path\`: on $(paste -sd ',' <<< "$events" | sed 's/,/, /g')") + fi + done + ((${#found[@]} == 0)) || fail "$UPSTREAM $3 adds workflow files that someone outside the fork can start, or whose triggers could not be read:" \ + "${found[@]}" "" \ + "The workflow sweep cannot stop the first run of such a file, so the series was left as it is." \ + "Add each one to $removed_file with a fork patch (the next follow then deletes it) and start the follow workflow again;" \ + "or, to keep one, rebase onto $3 by hand (see the conflict advice in fork-follow.yml)." +} + # The newest fork release of a tag: " ", or nothing. latest_release() { local last @@ -243,6 +314,11 @@ stage_report() { # stage_report CANDIDATE TAG CHANGED if [[ "$target_tag" == "$base_tag" ]]; then refuse_upstream_fork_named "$base" "$base_tag" "$head" + # The candidate here is the series head itself, so a listed file in it is refused, not deleted. + kept="$(present_removed "$head")" + [[ -z "$kept" ]] || fail "The series still has workflow files that $removed_file lists as deleted:" \ + "$(sed 's/^/- /' <<< "$kept")" "" \ + "The plan does not release them. Delete them with a fork patch (or take them off the list), then start the follow workflow again." read -r released_tag released_commit <<< "$(latest_release "$base_tag")" || true if [[ "${released_commit:-}" == "$head" ]]; then action=none @@ -269,11 +345,31 @@ if [[ "$target_tag" == "$base_tag" ]]; then fi # A newer upstream release: replay the series onto it. Patches upstream now has become empty and -# are dropped. +# are dropped. A stop whose conflicts are all on listed (deleted) workflow files is resolved by +# deleting them: upstream edited or brought back a file the fork deletes. target="$(up "$target_tag")" before="$(git log --format=%s "$base..HEAD")" -if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com \ - rebase --empty=drop --onto "$target" "$base" > "$logs/rebase.log" 2>&1; then +as_fork() { git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "$@"; } +rebasing() { [[ -d "$(git rev-parse --git-path rebase-merge)" || -d "$(git rev-parse --git-path rebase-apply)" ]]; } +rebase_ok=0 +if as_fork rebase --empty=drop --onto "$target" "$base" > "$logs/rebase.log" 2>&1; then + rebase_ok=1 +fi +steps=0 +while ((!rebase_ok)) && rebasing && ((steps++ <= series_count)); do + unmerged=() + while IFS= read -r -d '' path; do unmerged+=("$path"); done \ + < <(git -c core.quotepath=off diff -z --name-only --diff-filter=U) + ((${#unmerged[@]})) || break + for path in "${unmerged[@]}"; do is_removed "$path" || break 2; done + git rm -q --ignore-unmatch -- "${unmerged[@]}" >> "$logs/rebase.log" 2>&1 + if git diff --cached --quiet HEAD; then + GIT_EDITOR=true as_fork rebase --skip >> "$logs/rebase.log" 2>&1 && rebase_ok=1 + else + GIT_EDITOR=true as_fork rebase --continue >> "$logs/rebase.log" 2>&1 && rebase_ok=1 + fi +done +if ((!rebase_ok)); then stopped="$(git log -1 --format='%h %s' REBASE_HEAD 2> /dev/null || echo unknown)" conflicts="$(git diff --name-only --diff-filter=U | sed 's/^/- /')" git rebase --abort || true @@ -284,9 +380,19 @@ if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "\`--force-with-lease\`, and start the follow workflow once (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`):" \ "it releases the series and disables the upstream workflows that push started." fi +# Upstream brought back a listed file where no fork patch deletes it any more (the deleting patch +# became empty and was dropped once upstream had deleted the file itself): delete it again. +kept="$(present_removed HEAD)" +if [[ -n "$kept" ]]; then + mapfile -t kept_list <<< "$kept" + git rm -q -- "${kept_list[@]}" + as_fork commit -q -m "fork: delete upstream workflows listed in $removed_file" \ + -m "Upstream $target_tag has them again: $(paste -sd ' ' <<< "$kept")" +fi schema_check "$target" "After rebasing onto $target_tag, the series" candidate="$(git rev-parse HEAD)" refuse_upstream_fork_named "$target" "$target_tag" "$candidate" +refuse_outside_workflows "$base" "$target" "$target_tag" "$candidate" dropped="$(comm -23 <(sort <<< "$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" # A published fork release of the target with exactly these files exists (an earlier run published diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 22ecce78d4395..2e6c9a1c3f221 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -140,7 +140,8 @@ new_upstream() { } # The fork's checkout in $root/work: upstream rev $1 plus the fork's own commit (its workflow -# files and scripts, and feature.txt), pushed as the fork's main. +# files and scripts, an empty list of removed upstream workflows, and feature.txt), pushed as the +# fork's main. new_fork() { rm -rf "$root/work" git clone -q https://github.com/up/t3code.git "$root/work" 2> /dev/null @@ -152,6 +153,7 @@ new_fork() { cp "$here/fork-schema-check.cjs" .github/scripts/ printf 'jobs:\n desktop:\n uses: ./.github/workflows/release-desktop.yml\n' > .github/workflows/fork-release.yml echo "on: workflow_dispatch" > .github/workflows/fork-follow.yml + echo "# upstream workflow files the fork deletes" > .github/fork-removed-workflows.txt echo feature > feature.txt git add -A && git commit -q -m "fork: own change" git push -q --force origin main 2> /dev/null @@ -390,6 +392,142 @@ printf '#!/bin/sh\necho "Fork changes must not change the database schema (no do check "plan: a schema check refusal is reported as a schema change" \ eval 'has "The series changes the database schema" "$root/report.md" && has "a.sql" "$root/report.md"' +# Round 5: upstream workflow files the fork deletes (.github/fork-removed-workflows.txt) stay +# deleted on every follow, and a new upstream workflow that someone outside the fork can start +# stops the plan. Here the fork deletes upstream's ci.yml. +up_release() { # up_release TAG CMD...: one upstream commit on main made by CMD, tagged TAG + (cd "$root/upwork" && git checkout -q main && eval "${*:2}" && git add -A && git commit -q -m "$1" \ + && git tag "$1" && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +} +fork_commit() { # fork_commit MESSAGE CMD...: one fork commit made by CMD, pushed as main + (cd "$root/work" && eval "${*:2}" && git add -A && git commit -q -m "$1" && git push -q origin main) > /dev/null 2>&1 +} +fork_remove() { # fork_remove FILE...: a fork patch that lists and deletes upstream workflow files + local f cmd="" + for f in "$@"; do cmd+="echo .github/workflows/$f >> .github/fork-removed-workflows.txt && rm -f .github/workflows/$f && "; done + fork_commit "fork: delete $*" "${cmd}true" +} +in_candidate() { git -C "$root/work" cat-file -e "$(out candidate):.github/workflows/$1" 2> /dev/null; } + +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_remove ci.yml +plan +check "plan: upstream edits a deleted listed file -> the conflict is resolved by deleting it, rebase" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.11 ]] && ! in_candidate ci.yml && [[ ! -e "$root/report.md" ]]' +check "plan: the resolved rebase keeps the fork's patches and its list" \ + eval 'action_is rebase && git -C "$root/work" cat-file -e "$(out candidate):feature.txt" && git -C "$root/work" show "$(out candidate):.github/fork-removed-workflows.txt" | grep -qx .github/workflows/ci.yml' +# Re-run: an earlier run published this candidate, then stopped before the branch moved. The +# next run rebuilds the same files and advances to that release instead of building again. +released="$(out candidate)" +fork_tag v0.1.11-fork.1 "$released" +work git checkout -q main && work git reset -q --hard "$(series)" +plan +check "plan: re-run after a resolved deletion -> the same files, advance to the earlier release" \ + eval 'action_is advance && [[ "$(out candidate)" == "$released" ]]' + +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_remove ci.yml +fork_commit "fork: own u4" 'echo mine > u4.txt' +before="$(work_head)" +plan +check "plan: a conflict on a listed file and an unlisted one is refused, naming the unlisted one" \ + eval '[[ -z "$(out action)" ]] && has "u4.txt" "$root/report.md" && hasnt "\.github/workflows/ci.yml" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" && "$(work_head)" == "$before" ]]' + +# Upstream deleted the file itself (the fork's deleting patch has nothing left to do), then brings +# it back: the plan deletes it again with one more commit. +new_upstream +up_release v0.1.11 'git rm -q .github/workflows/ci.yml' +up_release v0.1.12 'echo "on: pull_request_target" > .github/workflows/ci.yml' +new_fork v0.1.11 +fork_commit "fork: list ci.yml" 'echo .github/workflows/ci.yml >> .github/fork-removed-workflows.txt' +plan +check "plan: upstream brings a listed file back -> deleted again on the candidate, rebase" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.12 ]] && ! in_candidate ci.yml && git -C "$root/work" log -1 --format=%s "$(out candidate)" | grep -q "fork: delete upstream workflows listed"' + +new_upstream +new_fork v0.1.10 +fork_commit "fork: list ci.yml" 'echo .github/workflows/ci.yml >> .github/fork-removed-workflows.txt' +plan +check "plan: same base, the series still has a listed file -> refused, naming it" \ + eval '[[ -z "$(out action)" ]] && has "still has workflow files" "$root/report.md" && has "ci.yml" "$root/report.md"' + +# Fail closed: no list, or a line that is not an upstream workflow path, stops the plan. +new_upstream +new_fork v0.1.10 +fork_commit "fork: lose the list" 'git rm -q .github/fork-removed-workflows.txt' +plan +check "plan: the list of removed workflows is missing -> refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "has no .github/fork-removed-workflows.txt" "$root/report.md"' +for bad in "ci.yml" ".github/workflows/fork-follow.yml" ".github/workflows/a b.yml" "../ci.yml"; do + new_fork v0.1.10 + fork_commit "fork: bad list" "echo '$bad' >> .github/fork-removed-workflows.txt" + plan + check "plan: a list line '$bad' -> refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "not an upstream workflow path" "$root/report.md"' +done +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_commit "fork: comments only" 'printf "# nothing\n\n # still nothing\n" > .github/fork-removed-workflows.txt' +plan +check "plan: a list with comments only deletes nothing, the plan goes on" \ + eval 'action_is rebase && in_candidate ci.yml' +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_commit "fork: delete ci.yml, CRLF list" "printf '# deleted\\r\\n.github/workflows/ci.yml\\r\\n' > .github/fork-removed-workflows.txt && rm .github/workflows/ci.yml" +plan +check "plan: a list with CRLF line ends is read like one with LF, the listed file stays deleted" \ + eval 'action_is rebase && ! in_candidate ci.yml && git -C "$root/work" show "$(out candidate):.github/fork-removed-workflows.txt" | grep -q $'"'"'ci.yml\r$'"'"'' + +# Upstream renames a listed file: git reports a rename/delete conflict on the new name, which the +# list does not name, so the plan stops and names it rather than shipping it under that name. +new_upstream +up_release v0.1.11 'git mv .github/workflows/ci.yml .github/workflows/ci-renamed.yml' +new_fork v0.1.10 +fork_remove ci.yml +plan +check "plan: upstream renames a listed file -> refused, naming the new name, rebase aborted" \ + eval '[[ -z "$(out action)" ]] && has "ci-renamed.yml" "$root/report.md" && hasnt "plan stopped at" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" ]]' + +# A new upstream workflow that someone outside the fork can start is refused unless listed. +for case in "pull_request_target|printf 'on:\n pull_request_target:\n types: [opened]\n'" \ + "workflow_run|printf 'name: x\non:\n workflow_run:\n workflows: [CI]\n'" \ + "issues|printf 'on: [push, issues]\n'" \ + "issue_comment|printf 'on: issue_comment # comments\n'" \ + "pull_request_review|printf 'on:\n pull_request_review:\n types: [submitted]\n'" \ + "pull_request_review_comment|printf 'on: [pull_request_review_comment]\n'" \ + "gollum|printf 'on: gollum\n'" \ + "on push|printf 'on: push\njobs:\n a:\n if: github.event_name == \"pull_request_target\"\n'" \ + "could not be read|printf 'on: {workflow_run: {workflows: [CI]}}\n'" \ + "could not be read|printf 'name: \"x\non: push\n\"\non : issues\n'" \ + "could not be read|printf 'jobs: {}\n'"; do + want="${case%%|*}" + new_upstream + up_release v0.1.11 "${case#*|} > .github/workflows/outside.yml" + new_fork v0.1.10 + plan + check "plan: upstream adds a workflow ($want) -> refused, naming it" \ + eval '[[ -z "$(out action)" ]] && has "outside.yml" "$root/report.md" && has "$want" "$root/report.md" && has "fork-removed-workflows.txt" "$root/report.md"' +done +new_upstream +up_release v0.1.11 "printf 'on:\n push:\n branches: [main]\npermissions:\n issues: write\njobs:\n issues:\n runs-on: x\n' > .github/workflows/inside.yml" +new_fork v0.1.10 +plan +check "plan: a new upstream workflow on push only (issues: as a permission and a job) goes on" \ + eval 'action_is rebase && in_candidate inside.yml' +new_upstream +up_release v0.1.11 "printf 'on: pull_request_target\n' > .github/workflows/outside.yml" +new_fork v0.1.10 +fork_commit "fork: list outside.yml" 'echo .github/workflows/outside.yml >> .github/fork-removed-workflows.txt' +plan +check "plan: a new outside-started upstream workflow that the list names -> deleted, rebase" \ + eval 'action_is rebase && ! in_candidate outside.yml' + # --- pushes: fork-follow-push.sh ------------------------------------------------------------- new_upstream new_fork v0.1.10 diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs index a8e7e9d19e871..5a458afb7d334 100644 --- a/.github/scripts/fork-schema-check.test.cjs +++ b/.github/scripts/fork-schema-check.test.cjs @@ -569,7 +569,7 @@ test("a workflow that pastes PR values into a script or reaches a token fails th // A plain one-line run continued on the next line: YAML folds it into the same script. "an expression on the continuation of a plain run": workflow.replace( /^( +)run: (node --test .*)$/m, - (all, indent, cmd) => `${all}\n${indent} \${{ github.head_ref }}`, + (all, indent) => `${all}\n${indent} \${{ github.head_ref }}`, ), }; for (const [label, text] of Object.entries(mutants)) { diff --git a/.github/scripts/fork-workflow-triggers.cjs b/.github/scripts/fork-workflow-triggers.cjs new file mode 100644 index 0000000000000..3a6f6790603a7 --- /dev/null +++ b/.github/scripts/fork-workflow-triggers.cjs @@ -0,0 +1,184 @@ +// Fork only: `node fork-workflow-triggers.cjs FILE` prints the events a workflow starts on (its +// top-level `on:`), one per line, so fork-follow.sh can refuse new upstream workflows outsiders can +// start. It reads only plain YAML: on any other shape it exits 2 with the reason on stderr, and the +// caller cannot clear the file. No dependencies. Tests: fork-workflow-triggers.test.cjs. +const fs = require("node:fs"); + +const EVENT = /^[a-z_]+$/; +const ON_KEY = /^(?:on|"on"|'on'):(?=[ \t]|$)(.*)$/; +const TOP_KEY = /^(?:[A-Za-z_][A-Za-z0-9_-]*|"on"|'on'):(?=[ \t]|$)/; +const ODD_BREAK = /\r(?!\n)|[\u0085\u2028\u2029]/; +// A line whose value is a block scalar (`run: |`, `- >-`, `key: &a |`): dashes, key, indicator. +const BLOCK_SCALAR = /^((?:-\s+)*)((?:[^\s#][^#]*?:\s+)?)(?:[&!]\S+\s+)*[|>][1-9+-]{0,2}$/; + +const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2"); +// Only ASCII spaces indent and only spaces and tabs are blank, as in YAML: a line that starts with +// a BOM or another Unicode space is unindented, so it must be a plain key. +const indentOf = (s) => s.match(/^ */)[0].length; +const blank = (s) => /^[ \t]*$/.test(s); + +// One line, read the way YAML reads it, inside DEPTH open brackets: `code` is the line without its +// comment, `quote` says a quoted scalar is still open at its end, `depth` how many brackets are. +// A quote or bracket starts a scalar or list only where a token starts (`name: it's` is text). +const scan = (line, depth) => { + let quote = null; + let tokenStart = true; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote === '"') { + if (c === "\\") i++; + else if (c === '"') [quote, tokenStart] = [null, false]; + continue; + } + if (quote === "'") { + if (c === "'" && line[i + 1] === "'") i++; + else if (c === "'") [quote, tokenStart] = [null, false]; + continue; + } + if (c === "#" && (i === 0 || /[ \t]/.test(line[i - 1]))) { + return { code: line.slice(0, i).trimEnd(), quote: false, depth }; + } + if (/[ \t]/.test(c)) continue; + if (tokenStart && (c === '"' || c === "'")) quote = c; + else if ((tokenStart || depth > 0) && (c === "[" || c === "{")) + [depth, tokenStart] = [depth + 1, true]; + else if (depth > 0 && (c === "]" || c === "}")) [depth, tokenStart] = [depth - 1, false]; + else if (depth > 0 && (c === "," || c === ":")) tokenStart = true; + else if ("-?:".includes(c) && (i + 1 === line.length || /[ \t]/.test(line[i + 1]))) + tokenStart = true; + else tokenStart = false; + } + return { code: line.trimEnd(), quote: quote !== null, depth }; +}; + +// The events TEXT (a workflow file) starts on, sorted. Throws when it cannot tell for sure. +const readTriggers = (text) => { + const unsure = (why) => { + throw new Error(`cannot read the triggers: ${why}`); + }; + // YAML 1.1 also breaks lines at these, which a split on \n would not see. + if (ODD_BREAK.test(text)) + unsure("a line break other than \\n or \\r\\n (a lone \\r, U+0085, U+2028 or U+2029)"); + const lines = text.split(/\r?\n/); + if (lines.some((l) => /^\s*\t/.test(l))) unsure("a line is indented with a tab"); + + // The code of each line (null: blank, a comment, a block scalar's text, a bracket list's later + // lines). A quote left open at a line's end could carry a decoy `on:`, so it is refused. A list + // may go over lines (upstream's `needs:` do) if those lines are indented more than its first; a + // last line of only closing brackets may line up with it. + const codes = []; + let blockAbove = -1; // a block scalar's text is the lines indented more than this + let depth = 0; // brackets open at the end of the last line + let flowFrom = -1; // the line that opened them + lines.forEach((line, i) => { + if (blockAbove !== -1 && (blank(line) || indentOf(line) > blockAbove)) return codes.push(null); + blockAbove = -1; + const scanned = scan(line, depth); + if (scanned.quote) unsure(`a quote is left open at the end of line ${i + 1}`); + if (depth > 0) { + const [here, from] = [indentOf(line), indentOf(lines[flowFrom])]; + const closing = /^[\]}]+$/.test(scanned.code.trim()) && scanned.depth === 0; + if (!blank(scanned.code) && !(here > from || (closing && here === from))) { + unsure( + `line ${i + 1} is inside the bracket opened on line ${flowFrom + 1} but not indented more than that line`, + ); + } + depth = scanned.depth; + return codes.push(null); + } + ({ depth } = scanned); + if (depth > 0) flowFrom = i; + const { code } = scanned; + if (blank(code)) return codes.push(null); + const body = code.trim(); + const block = body.match(BLOCK_SCALAR); + if (block) { + // Its text is indented more than its key, or than its `-` when it has no key. + const [, dashes, key] = block; + blockAbove = indentOf(code) + (key ? dashes.length : dashes ? dashes.lastIndexOf("-") : -1); + } + codes.push(code); + }); + if (depth > 0) unsure(`the bracket opened on line ${flowFrom + 1} is never closed`); + + let found = -1; + let seenContent = false; + codes.forEach((code, i) => { + if (code === null || indentOf(code) !== 0) return; + if (code === "---" || code === "...") { + if (seenContent) unsure("more than one YAML document"); + return; + } + seenContent = true; + // This refuses `on :`, escaped or other quoted keys, `? on`, directives and a root list. + if (!TOP_KEY.test(code)) unsure(`the unindented line \`${code}\` is not a plain \`key:\``); + if (ON_KEY.test(code)) { + if (found !== -1) unsure("`on:` appears twice"); + found = i; + } + }); + if (found === -1) unsure("no top-level `on:`"); + + const events = []; + const name = (raw) => { + const event = unquote(raw); + if (!EVENT.test(event)) unsure(`\`${raw.trim()}\` is not an event name`); + events.push(event); + }; + const rest = codes[found].match(ON_KEY)[1].trim(); + if (/^\[[^[\]{}]*\]$/.test(rest)) { + rest + .slice(1, -1) + .split(",") + .filter((s) => s.trim() !== "") + .forEach(name); + } else if (/^[[{&*|>!]/.test(rest)) { + unsure(`\`on: ${rest}\` is a form this reader does not take`); + } else if (rest !== "") { + name(rest); + } else { + let childIndent = -1; + for (const code of codes.slice(found + 1)) { + if (code === null) continue; + const indent = indentOf(code); + if (indent === 0) break; + if (childIndent === -1) childIndent = indent; + if (indent < childIndent) unsure("the `on:` block is indented unevenly"); + if (indent > childIndent) continue; + const body = code.trim(); + const item = body.match(/^-\s+(.*)$/); + const key = body.match(/^("[^"]*"|'[^']*'|[^\s:"']+)\s*:(?=[ \t]|$)/); + if (item) name(item[1]); + else if (key && key[1] !== "<<") name(key[1]); + else unsure(`\`${body}\` under \`on:\``); + } + if (childIndent === -1) unsure("`on:` is empty"); + } + return [...new Set(events)].sort(); +}; + +const main = () => { + try { + let text; + try { + text = fs.readFileSync(process.argv[2], "utf8"); + } catch (error) { + throw new Error( + `cannot read the triggers: cannot read ${process.argv[2]}: ${error.message}`, + { + cause: error, + }, + ); + } + const events = readTriggers(text); + process.stdout.write(events.map((e) => `${e}\n`).join("")); + } catch (error) { + // Any failure, a bug included, means "cannot tell", which the caller refuses. + process.stderr.write(`${error.message}\n`); + process.exit(2); + } +}; + +module.exports = { readTriggers }; + +if (require.main === module) main(); diff --git a/.github/scripts/fork-workflow-triggers.test.cjs b/.github/scripts/fork-workflow-triggers.test.cjs new file mode 100644 index 0000000000000..0b9373d108ee9 --- /dev/null +++ b/.github/scripts/fork-workflow-triggers.test.cjs @@ -0,0 +1,197 @@ +// Tests for fork-workflow-triggers.cjs: what it reads, and every shape it refuses. +// node --test .github/scripts/fork-workflow-triggers.test.cjs +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { readTriggers } = require("./fork-workflow-triggers.cjs"); + +const SCRIPT = path.join(__dirname, "fork-workflow-triggers.cjs"); +const yml = (...lines) => `${lines.join("\n")}\n`; +const refuses = (text, why) => assert.throws(() => readTriggers(text), why); + +test("reads the top-level on: in each plain form", () => { + assert.deepEqual(readTriggers(yml("on: push")), ["push"]); + assert.deepEqual(readTriggers(yml("on: [push, issues] # two")), ["issues", "push"]); + assert.deepEqual(readTriggers(yml("'on': 'issues'")), ["issues"]); + assert.deepEqual( + readTriggers( + yml('"on":', " push:", " branches: [main]", " - fork", ' "watch":', "jobs: {}"), + ), + ["fork", "push", "watch"], + ); +}); + +test("issues: as a permission or a job is not a trigger", () => { + const text = yml( + "on:", + " push:", + "permissions:", + " issues: write", + "jobs:", + " issues:", + " runs-on: x", + ); + assert.deepEqual(readTriggers(text), ["push"]); +}); + +test("takes what upstream writes: CRLF, block scalars, quotes in text, a needs list over lines", () => { + const text = yml( + 'name: it\'s "fine" # a comment', + "on:", + " push: # on: issues", + "jobs:", + " check:", + ' name: "a #b"', + " needs:", + " [", + " build,", + " test,", + " ]", + " steps:", + " - run: |", + " echo \"it's open [", + "", + " 'it's [ open", + " shell: bash", + " - with:", + " args: >-", + " don't", + " - |", + ' "open', + " - >2", + " [open", + "on_more: x", + ); + assert.deepEqual(readTriggers(text), ["push"]); + assert.deepEqual(readTriggers(text.replace(/\n/g, "\r\n")), ["push"]); +}); + +test("refuses a line break other than \\n or \\r\\n", () => { + for (const nl of ["\r", "\u0085", "\u2028", "\u2029"]) { + refuses(`on:\n push: {}${nl} issues: {}\n`, /line break other than/); + } +}); + +test("refuses a tab in the indent", () => { + refuses("on:\n\tissues:\n", /indented with a tab/); +}); + +test("refuses an unindented line that is not a plain key", () => { + for (const line of [ + "on : issues", + '"o\\x6en": [issues]', + "? on", + "%YAML 1.2", + "- push", + "{on: issues}", + "\uFEFFon: push", + "\u00a0on: push", + "true :", + ]) { + refuses(yml(line, "jobs: {}"), /is not a plain `key:`/); + } + refuses(yml("on:", "- issues"), /is not a plain `key:`/); +}); + +test("refuses a quote left open at the end of a line (a decoy on: inside a string)", () => { + refuses( + yml('name: "x', "on: push", '"', "on : issues"), + /quote is left open at the end of line 1/, + ); + refuses(yml("name: 'x", "on: push", "'"), /quote is left open at the end of line 1/); + refuses( + yml("on: push", "jobs:", " a:", ' name: "x \\"', ' y"'), + /quote is left open at the end of line 4/, + ); +}); + +test("reads the lines after a block scalar's text again", () => { + refuses( + yml( + "on: push", + "jobs:", + " a:", + " steps:", + " - run: |", + " x", + ' name: "open', + ), + /line 7/, + ); + refuses(yml("on: push", "x:", " - |", " y", ' - "open'), /line 5/); + refuses(yml("name: |", " text", "on : issues"), /is not a plain `key:`/); +}); + +test("refuses a bracket list whose lines are not indented more than its first line", () => { + const decoy = yml( + "jobs:", + " a:", + " strategy:", + " matrix: [x,", + "on: push", + "]", + '"on": [issues]', + ); + refuses(decoy, /line 5 is inside the bracket opened on line 4/); + refuses( + yml("on: push", "jobs:", " a:", " needs: [", " a,", " ] x"), + /line 6 is inside the bracket/, + ); + refuses( + yml("on: push", "jobs:", " a:", " needs: [", " a,"), + /bracket opened on line 4 is never closed/, + ); +}); + +test("refuses on: twice and more than one document", () => { + refuses(yml("on: push", "jobs: {}", "on: issues"), /`on:` appears twice/); + refuses(yml("on: push", '"on": issues'), /`on:` appears twice/); + refuses(yml("---", "on: push", "---", "on: issues"), /more than one YAML document/); + refuses(yml("on: push", "...", "on: issues"), /more than one YAML document/); + assert.deepEqual(readTriggers(yml("--- # one document", "on: push")), ["push"]); +}); + +test("refuses no on:, an empty on: and uneven indent under on:", () => { + refuses(yml("jobs: {}"), /no top-level `on:`/); + refuses(yml("name: x", " on: issues"), /no top-level `on:`/); + refuses(yml("on:", "jobs: {}"), /`on:` is empty/); + refuses(yml("on:", " push:", " issues:"), /indented unevenly/); +}); + +test("refuses a list over lines and every other form under on:", () => { + refuses(yml("on: [push,", " issues]"), /form this reader does not take/); + for (const rest of ["{push: {}}", "&a push", "*a", "|", ">-", "!!str push", "[push, [issues]]"]) { + refuses(yml(`on: ${rest}`, " x"), /form this reader does not take/); + } + refuses(yml("on:", " <<: *a"), /`<<: \*a` under `on:`/); + refuses(yml("on:", " ? issues"), /`\? issues` under `on:`/); + refuses(yml("on: Issues"), /`Issues` is not an event name/); + refuses(yml("on: [push, 'iss\\x75es']"), /is not an event name/); + refuses(yml("on:", " - pull_request_target: x"), /is not an event name/); +}); + +test("the command prints one event per line, and exits 2 with the reason when unsure", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-triggers-")); + try { + const file = path.join(dir, "w.yml"); + fs.writeFileSync(file, yml("on: [workflow_run, push]")); + let run = spawnSync(process.execPath, [SCRIPT, file], { encoding: "utf8" }); + assert.equal(run.status, 0); + assert.equal(run.stdout, "push\nworkflow_run\n"); + fs.writeFileSync(file, yml("jobs: {}")); + run = spawnSync(process.execPath, [SCRIPT, file], { encoding: "utf8" }); + assert.equal(run.status, 2); + assert.equal(run.stdout, ""); + assert.match(run.stderr, /^cannot read the triggers: no top-level `on:`\n$/); + run = spawnSync(process.execPath, [SCRIPT, path.join(dir, "missing.yml")], { + encoding: "utf8", + }); + assert.equal(run.status, 2); + assert.match(run.stderr, /^cannot read the triggers: cannot read .*missing\.yml/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml deleted file mode 100644 index 0213f3fce6a99..0000000000000 --- a/.github/workflows/desktop-macos-preview.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Desktop macOS Preview - -# Untrusted half of the macOS preview. This runs PR code (including fork PRs) -# with a read-only token and no secrets, and only produces the JS bundle. The -# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from -# main, verifies the PR author is vouched, then packages, signs, notarizes, and -# publishes the bundle without ever executing it. -# -# The label is a one-shot request for the commit it was applied to, not a -# standing subscription: the trusted half removes it once this run completes, -# and later pushes do not build until a maintainer applies it again. Each -# signed preview is therefore an explicit per-commit decision. -# -# Closing the PR is handled by the publish workflow too, since deleting the -# download needs a write token. - -on: - pull_request: - types: [labeled] - -permissions: - contents: read - -concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }} - # Adding an unrelated label skips the job and must not cancel a build. - cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }} - -jobs: - build: - name: Build preview JS bundle - if: github.event.label.name == 'preview:mac' - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 30 - steps: - # This repository is public, so Git needs no credentials. checkout's - # credential cleanup runs submodule foreach even with submodules disabled, - # which fails on the orphaned gitlinks in our vendored .repos tree. - - name: Checkout - shell: bash - env: - CHECKOUT_REF: ${{ github.event.pull_request.head.sha }} - GIT_TERMINAL_PROMPT: "0" - run: | - set -euo pipefail - git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" - git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - - - name: Install bundle dependencies - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - - # The publish workflow derives the same version from this run's number, - # so the version baked into the bundle matches the packaged app. - - name: Set preview version and public configuration - shell: bash - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - base_version="$(node -p "require('./apps/desktop/package.json').version")" - node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - # Public T3 Connect identifiers (Clerk publishable key, relay URL). - cp .env.example .env - - # PR heads from before build:desktop:bundle use the full build, which - # compiles the Linux browser secret helper and needs libsecret. - - name: Check bundle-only build support - id: bundle_build - run: | - script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" - echo "script=$script" >> "$GITHUB_OUTPUT" - - - if: steps.bundle_build.outputs.script == 'build:desktop' - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries - if: steps.bundle_build.outputs.script == 'build:desktop' - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - # desktop-macos-preview-publish.yml packages this bundle as the preview - # channel. The Linux browser secret helper is not part of a macOS app. - - name: Build JS bundle - env: - T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && 'preview' || 'dev' }} - run: vp run ${{ steps.bundle_build.outputs.script }} - - # Same layout as release.yml's js-bundle so release-desktop.yml can - # package it unchanged. - - name: Upload JS bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: js-bundle - path: | - apps/server/dist - apps/desktop/dist-electron - if-no-files-found: error - retention-days: 1 diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 16df6246985b0..8761834658dbf 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -12,6 +12,10 @@ name: Fork follow upstream # them, or this is a re-run or queued run of an older commit). A run uses the reusable workflows # of its own commit, so the candidate is pushed and the run stops with the command that releases # it with its own files; the next run then finds that release (and advances to it). +# Upstream workflow files listed in .github/fork-removed-workflows.txt stay deleted: the plan +# resolves a rebase conflict on them by deleting them and deletes any that upstream brings back. +# A missing or malformed list, and a new upstream workflow that someone outside the fork can start +# (pull_request_target, workflow_run, issues ...; fork-follow.sh) and the list does not name, stop it. # A conflict, merge commits in the series, a schema change, upstream commits inside the series, a # moved or deleted upstream tag, a red test, a failed build or push: the run fails, and the Report # job's summary says why and what the repository holds now (branch heads, release tags, drafts), as diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml deleted file mode 100644 index a8bf5725af70c..0000000000000 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ /dev/null @@ -1,355 +0,0 @@ -name: Mobile EAS Preview (Fork) - -# EAS previews for fork PRs. This runs the PR's code with an Expo token: the -# app config and plugins run on this runner, the iOS client builds on EAS and -# the Android one on Blacksmith (android-build), with preview signing credentials. The label is the maintainer's sign-off -# that they reviewed that code, so it covers exactly the commit it was applied -# to: this pins that commit and consumes the label before running anything. A -# later push deploys nothing until the label is applied again. -# -# Same-repo PRs deploy on every push while labeled, via mobile-eas-preview.yml. - -on: - pull_request_target: - types: [labeled] - -permissions: - contents: read - -jobs: - preview: - name: EAS Preview - if: >- - github.event.pull_request.head.repo.full_name != github.repository && - github.event.label.name == '🚀 Mobile Continuous Deployment' - runs-on: blacksmith-8vcpu-ubuntu-2404 - concurrency: - group: mobile-eas-preview-fork-${{ github.event.pull_request.number }} - cancel-in-progress: true - permissions: - contents: read - pull-requests: write - outputs: - build: ${{ steps.client.outputs.build }} - fingerprint: ${{ steps.client.outputs.fingerprint }} - env: - APP_VARIANT: preview - NODE_OPTIONS: --max-old-space-size=8192 - MOBILE_VERSION_POLICY: fingerprint - steps: - # If a maintainer removed the label first, that removal wins. - - id: consume - name: Consume the deployment label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: context.payload.label.name, - }); - core.setOutput("consumed", "true"); - } catch (error) { - if (error.status !== 404) throw error; - core.info("The label was removed before this run could consume it. Skipping."); - core.setOutput("consumed", "false"); - } - - - id: expo-token - name: Check for EXPO_PREVIEW_TOKEN - if: steps.consume.outputs.consumed == 'true' - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - if [ -n "$EXPO_TOKEN" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_PREVIEW_TOKEN is not set; skipping EAS preview." - fi - - # The head SHA from the label event, not the branch, so a push after the - # label cannot swap in unreviewed code. No credentials stay in .git for - # the PR's scripts to read. - - name: Checkout reviewed commit - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - # No sparse-checkout: see mobile-eas-preview.yml. - - # The comment script comes from main, not from the PR. - - name: Checkout trusted scripts - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - path: .trusted - sparse-checkout: .github/scripts - - # The steps below run the PR's dependency and build scripts; they get no token. - # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both - # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule - # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). - - name: Drop checkout credentials - if: steps.expo-token.outputs.present == 'true' - run: | - for dir in . .trusted; do - [ -e "$dir/.git" ] || continue - git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do - rm -f "$file" - git -C "$dir" config --local --unset-all "$key" - done - git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do - git -C "$dir" config --local --unset-all "$key" - done - if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then - echo "::error::checkout credentials are still configured in $dir" - exit 1 - fi - done - rm -f "$RUNNER_TEMP"/git-credentials-*.config - if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then - echo "::error::a checkout credential file is still present" - exit 1 - fi - - # No caches: pull_request_target saves them under main's scope, where - # trusted workflows with other secrets would restore what PR code wrote. - - name: Setup Vite+ - if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: false - run-install: | - args: - - --filter=@t3tools/mobile... - - - name: Expose pnpm - if: steps.expo-token.outputs.present == 'true' - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Setup EAS - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 - with: - # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. - eas-version: 21.8.0 - eas-cache: false - token: ${{ secrets.EXPO_PREVIEW_TOKEN }} - packager: npm - - - name: Pull preview environment variables - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - eas env:pull preview --non-interactive - # See mobile-eas-preview.yml: keeps Android build and OTA fingerprints matching. - node --input-type=module <<'NODE' - import { appendFileSync, readFileSync } from "node:fs"; - import { parseEnv } from "node:util"; - const env = parseEnv(readFileSync(".env.local", "utf8")); - const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; - if (googleServicesFile) { - appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); - } - NODE - - - name: Deploy with fingerprint check - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - RUNNER_DEBUG: "1" - # The action only comments for pull_request events; this hook gives - # it the PR under pull_request_target. Without it, it skips the comment. - EXPO_TEST_GITHUB_PULL: ${{ github.event.pull_request.number }} - with: - profile: preview:dev - branch: pr-${{ github.event.pull_request.number }} - # Android is below: its client builds on Blacksmith (android-build). - platform: ios - environment: preview - working-directory: apps/mobile - github-token: ${{ secrets.GITHUB_TOKEN }} - - # Android, as in mobile-eas-preview.yml: the same fingerprint command the Expo - # action runs. A client built for a same-repo PR (its tag) or by EAS is reused. - - id: client - name: Find the Android client for this fingerprint - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - shell: bash - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" - echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" - tag="mobile-android-preview-build/$hash" - link="" - if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then - link="$(git tag -l --format='%(contents:subject)' "$tag")" - else - link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ - | sed -n '/^\[/,$p' \ - | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" - fi - echo "link=$link" >> "$GITHUB_OUTPUT" - if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi - - - id: update - name: Publish the Android update - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - shell: bash - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - PR: ${{ github.event.pull_request.number }} - run: | - group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" - echo "group=$group" >> "$GITHUB_OUTPUT" - - - name: Comment - if: steps.expo-token.outputs.present == 'true' - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - FINGERPRINT: ${{ steps.client.outputs.fingerprint }} - CLIENT: ${{ steps.client.outputs.link }} - GROUP: ${{ steps.update.outputs.group }} - run: node .trusted/.github/scripts/mobile-preview-comment.cjs - - # The reviewed commit's Android client, built cold: no sticky disk, because PR - # code could leave Gradle or ccache state that trusted builds would restore, and - # no build record tag, so nobody else is served an APK a fork's code built. - android-build: - name: Android preview client - needs: preview - if: needs.preview.outputs.build == 'true' - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 60 - permissions: - contents: read - pull-requests: write - env: - APP_VARIANT: preview - NODE_OPTIONS: --max-old-space-size=8192 - MOBILE_VERSION_POLICY: fingerprint - FINGERPRINT: ${{ needs.preview.outputs.fingerprint }} - # Phones only: emulators use the development client from the native-client cache. - ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a - steps: - - name: Checkout reviewed commit - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - # The comment script comes from main, not from the PR. - - name: Checkout trusted scripts - uses: actions/checkout@v6 - with: - path: .trusted - sparse-checkout: .github/scripts - - # The steps below run the PR's dependency and build scripts; they get no token. - # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both - # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule - # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). - - name: Drop checkout credentials - run: | - for dir in . .trusted; do - [ -e "$dir/.git" ] || continue - git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do - rm -f "$file" - git -C "$dir" config --local --unset-all "$key" - done - git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do - git -C "$dir" config --local --unset-all "$key" - done - if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then - echo "::error::checkout credentials are still configured in $dir" - exit 1 - fi - done - rm -f "$RUNNER_TEMP"/git-credentials-*.config - if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then - echo "::error::a checkout credential file is still present" - exit 1 - fi - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Enable corepack - run: | - npm install --global corepack@latest - corepack enable - pnpm --version - - - name: Install mobile dependencies - run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - - name: Setup EAS - uses: expo/expo-github-action@v8 - with: - eas-version: latest - eas-cache: false - token: ${{ secrets.EXPO_PREVIEW_TOKEN }} - packager: npm - - - name: Pull preview environment variables - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - eas env:pull preview --non-interactive - node --input-type=module <<'NODE' - import { appendFileSync, readFileSync } from "node:fs"; - import { parseEnv } from "node:util"; - const env = parseEnv(readFileSync(".env.local", "utf8")); - const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; - if (googleServicesFile) { - appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); - } - NODE - - - id: build - name: Build and upload - working-directory: apps/mobile - shell: bash - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" - eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" - link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" - if [ -z "$link" ]; then - echo "::error::eas upload returned no install link" - exit 1 - fi - echo "link=$link" >> "$GITHUB_OUTPUT" - - - name: Comment - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - CLIENT: ${{ steps.build.outputs.link }} - run: node .trusted/.github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml deleted file mode 100644 index 45e4c9f2ad42d..0000000000000 --- a/.github/workflows/mobile-fingerprint-label.yml +++ /dev/null @@ -1,189 +0,0 @@ -name: Mobile Fingerprint Label - -# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code -# (fork PRs included) with a read-only token and records which platforms' -# fingerprints changed. This one runs from main with a write token and syncs -# the "📱 Native Change" label. It never checks out or runs PR code. -# -# A PR can edit the check workflow it runs under, so the recorded result is -# untrusted data: it is validated, and at worst a PR mislabels itself. - -on: - workflow_run: - workflows: [Mobile Fingerprint Check] - types: [completed] - -permissions: - contents: read - -jobs: - label: - name: Sync native change label - # A cancelled run was superseded by a newer push, whose run labels instead. - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - actions: read - contents: read - issues: write - pull-requests: write - steps: - - id: pr - name: Resolve the pull request behind the check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const run = context.payload.workflow_run; - const { owner, repo } = context.repo; - - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: 100, - }); - const results = artifacts.filter( - (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, - ); - if (results.length !== 1) { - core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); - return; - } - - // workflow_run.pull_requests and the commit-to-PR lookup are both - // empty for fork PRs, so list open PRs by head owner and branch. - // Requiring the PR's current head to be the checked commit keeps a - // slow run for an older push from overwriting a newer result. - const open = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - head: `${run.head_repository?.owner?.login}:${run.head_branch}`, - per_page: 100, - }); - const matching = open.filter( - (candidate) => - candidate.state === "open" && - candidate.head.sha === run.head_sha && - candidate.head.ref === run.head_branch && - candidate.head.repo?.full_name === run.head_repository?.full_name, - ); - if (matching.length !== 1) { - core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); - return; - } - - core.setOutput("artifact_id", String(results[0].id)); - core.setOutput("pr_number", String(matching[0].number)); - - # Read the archive as bytes; never extract PR-produced files into a - # workspace this job runs code from. - - id: result - name: Read changed platforms - if: steps.pr.outputs.pr_number != '' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} - run: | - set -euo pipefail - gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" - changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" - if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then - echo "Unexpected changed-platforms value '$changed'." >&2 - exit 1 - fi - echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" - - - name: Sync native change label - if: steps.pr.outputs.pr_number != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - PR_NUMBER: ${{ steps.pr.outputs.pr_number }} - CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "📱 Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = Number(process.env.PR_NUMBER); - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml deleted file mode 100644 index 23eec72923bd8..0000000000000 --- a/.github/workflows/thread-transfer-report.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Thread Transfer Report - -on: - workflow_run: - workflows: [CI] - types: [completed] - -permissions: - actions: read - contents: read - pull-requests: write - -jobs: - publish: - name: Publish PR comment - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-24.04 - concurrency: - group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} - cancel-in-progress: true - steps: - # workflow_run has a write-capable token even for fork PRs. Only load the - # publisher from the trusted default branch and never execute PR code. - - name: Checkout trusted publisher - uses: actions/checkout@v6 - with: - ref: ${{ github.event.repository.default_branch }} - sparse-checkout: .github/scripts - - - name: Test trusted publisher - run: node --test .github/scripts/thread-transfer-report.test.cjs - - - id: resolve - name: Resolve PR and baseline artifacts - uses: actions/github-script@v8 - with: - script: | - const reporter = require("./.github/scripts/thread-transfer-report.cjs"); - await reporter.resolve({ github, context, core }); - - - name: Download PR result - if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer/pr - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.resolve.outputs.pr_run_id }} - - - name: Download main baseline - if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer/main - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.resolve.outputs.baseline_run_id }} - - - name: Update thread transfer comment - if: steps.resolve.outputs.publish == 'true' - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} - PR_SHA: ${{ steps.resolve.outputs.pr_sha }} - PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} - PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} - PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr - BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} - BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} - BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} - BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main - with: - script: | - const reporter = require("./.github/scripts/thread-transfer-report.cjs"); - await reporter.publish({ github, context, core }); From 1f8f6e5e88a5f051b57be8feb689cf6b5b68df4a Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:10:28 +0100 Subject: [PATCH 10/27] fork: follow upstream's newest nightly tag (FORK_FOLLOW_CHANNEL, default nightly) (#22) * fork: follow upstream's newest nightly tag (FORK_FOLLOW_CHANNEL, default nightly) Fork-follow and fork-release follow one upstream channel, chosen by the repository variable FORK_FOLLOW_CHANNEL: nightly (default; tags vX.Y.Z-nightly.YYYYMMDD.N, released as .fork.) or stable (vX.Y.Z, released as -fork., as before). - fork-channel.sh (new, sourced by both scripts and the report): the channel, the fork's release names, and one numeric version order (run 1000 above run 999; a form it cannot order fails). - Plan: newest nightly by version; a series on an untagged commit of upstream's default branch is rebased onto the newest nightly that contains it, and refused while none does; a newest nightly that does not contain the series' base is refused; the deleted/moved tag checks read .fork.N too; fetches prune, so a deleted tag is gone in a reused checkout. - Release: nightly builds only a tag whose upstream release is published (not a draft); a published .fork.N now orders against every other build, so a lower one is refused. - Report: names .fork.N releases at the candidate. Co-Authored-By: Claude Opus 5.5 * fork: security-lens minors on the nightly follow (#22) - fork-channel.sh: version_key ranks a plain X.Y.Z above every pre-release of it, as the app's compareSemverVersions does (was: a nightly fork build or X.Y.Z-fork.N above a plain X.Y.Z). - refuse_older: a nightly build of the same X.Y.Z as a published X.Y.Z-fork.N must contain that release's upstream tag (GitHub compare); upstream names a nightly after the release it leads up to, so its fork build sorts above the stable one but holds older code. - Plan and resolve: the nightly must be on upstream's default branch. - publish re-checks that upstream still has the base released (a nightly withdrawn during the build); resolve and publish share require_released. The publish step gets UPSTREAM. - Tests: each rule red without it; a test for "upstream rewrote main past the series' nightly". Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/scripts/fork-channel.sh | 72 +++++++++++ .github/scripts/fork-follow-report.sh | 4 +- .github/scripts/fork-follow.sh | 77 ++++++++---- .github/scripts/fork-follow.test.sh | 146 ++++++++++++++++++++++ .github/scripts/fork-release.sh | 152 +++++++++++++++-------- .github/scripts/fork-release.test.sh | 171 ++++++++++++++++++++++++++ .github/workflows/fork-follow.yml | 26 ++-- .github/workflows/fork-release.yml | 20 +-- 8 files changed, 580 insertions(+), 88 deletions(-) create mode 100644 .github/scripts/fork-channel.sh diff --git a/.github/scripts/fork-channel.sh b/.github/scripts/fork-channel.sh new file mode 100644 index 0000000000000..fb424e2469c49 --- /dev/null +++ b/.github/scripts/fork-channel.sh @@ -0,0 +1,72 @@ +# Sourced by fork-follow.sh and fork-release.sh: which upstream tags the fork follows, how its own +# releases are named, and the version order both use. Tests: fork-follow.test.sh, fork-release.test.sh. +# +# FORK_FOLLOW_CHANNEL (the repository variable of that name; default nightly): +# nightly upstream tags vX.Y.Z-nightly.YYYYMMDD.N; the fork's release of one is .fork. +# stable upstream tags vX.Y.Z; the fork's release of one is -fork. +# Both fork forms end in something other than -nightly.YYYYMMDD.N, so the app's updaters read every +# fork release as their stable track (packages/shared/src/cliRelease.ts cliReleaseChannelOf): +# the fork has one track, and its builds keep the stable update feed and branding. + +fork_re='^v[0-9]+\.[0-9]+\.[0-9]+(-fork|-nightly\.[0-9]{8}\.[0-9]+\.fork)\.[0-9]+$' + +fork_channel_init() { # sets channel, channel_re and channel_form; fails on an unknown channel + channel="${FORK_FOLLOW_CHANNEL:-nightly}" + case "$channel" in + nightly) channel_re='^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$' channel_form=vX.Y.Z-nightly.YYYYMMDD.N ;; + stable) channel_re='^v[0-9]+\.[0-9]+\.[0-9]+$' channel_form=vX.Y.Z ;; + *) return 1 ;; + esac +} + +# The separator before fork. for an upstream tag: "." after a nightly tag (which already has a +# pre-release part, so the fork number extends it), "-" after a stable one. +fork_sep() { if [[ "$1" == *-nightly.* ]]; then printf .; else printf -- -; fi; } + +# version_key VERSION: "X Y Z KIND DATE RUN FORK", numbers only, for vX.Y.Z, vX.Y.Z-fork.N, +# vX.Y.Z-nightly.D.R and vX.Y.Z-nightly.D.R.fork.N (leading v optional); fails on any other form. +# KIND: 1 stable fork, 2 nightly, 3 plain. Within one X.Y.Z this is semver's order, which the app's +# updaters use (packages/shared/src/semver.ts compareSemverVersions): every pre-release sorts below +# X.Y.Z itself, and a nightly sorts above a stable fork release ("nightly" > "fork"). Numbers +# compare as numbers: run 1000 is above run 999 (a plain sort of the names gets that wrong). +# A version is not an age: upstream names a nightly after the release it leads up to, so +# vX.Y.Z-nightly.D.N.fork.M sorts above X.Y.Z-fork.N although its code can be older +# (fork-release.sh refuse_older checks that pair by ancestry). +version_key() { + local re='^v?([0-9]+)\.([0-9]+)\.([0-9]+)(-fork\.([0-9]+)|-nightly\.([0-9]{8})\.([0-9]+)(\.fork\.([0-9]+))?)?$' + [[ "$1" =~ $re ]] || return 1 + local m=("${BASH_REMATCH[@]}") + if [[ -n "${m[5]}" ]]; then + echo "${m[1]} ${m[2]} ${m[3]} 1 0 0 ${m[5]}" + elif [[ -n "${m[6]}" ]]; then + echo "${m[1]} ${m[2]} ${m[3]} 2 ${m[6]} ${m[7]} ${m[9]:-0}" + else + echo "${m[1]} ${m[2]} ${m[3]} 3 0 0 0" + fi +} + +# newest_first: the versions on stdin (forms above only), newest first by version_key, never by +# name or by when a tag was made. A line of another form fails it. +newest_first() { + local t key + while IFS= read -r t; do + [[ -n "$t" ]] || continue + key="$(version_key "$t")" || return 1 + printf '%s %s\n' "$key" "$t" + done | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr -k5,5nr -k6,6nr -k7,7nr | cut -d' ' -f8 +} + +# version_gt A B: A sorts above B by version_key. Fails (exit 2) on a form it cannot order. +version_gt() { + local -a x y + local i + read -r -a x <<< "$(version_key "$1")" && read -r -a y <<< "$(version_key "$2")" || return 2 + ((${#x[@]} == 7 && ${#y[@]} == 7)) || return 2 + for i in 0 1 2 3 4 5 6; do + if ((10#${x[i]} != 10#${y[i]})); then + ((10#${x[i]} > 10#${y[i]})) + return + fi + done + return 1 +} diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh index aacb431b5fd28..28db46552939b 100755 --- a/.github/scripts/fork-follow-report.sh +++ b/.github/scripts/fork-follow-report.sh @@ -16,6 +16,8 @@ set -uo pipefail # Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). . "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" +# fork_re: the names of the fork's releases. +. "$(dirname "${BASH_SOURCE[0]}")/fork-channel.sh" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-https://github.com/$REPOSITORY.git}" @@ -78,7 +80,7 @@ if refs="$(git ls-remote "$ORIGIN" "refs/heads/$SERIES_BRANCH" refs/heads/fork-f tags="" if [[ -n "${CANDIDATE:-}" ]]; then tags="$(awk -v sha="$CANDIDATE" '$1 == sha { t = $2; sub("refs/tags/", "", t); sub(/\^\{\}$/, "", t); print t }' <<< "$refs" \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' | sort -uV)" + | grep -E "$fork_re" | sort -uV)" fi if [[ -z "${CANDIDATE:-}" ]]; then row "Release" "none: the plan did not pick a commit" diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index 637780154199b..6c49199c03d5f 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash # Plan step of fork-follow.yml: decides what the run does, and rebases the fork's patch series onto -# a newer stable upstream tag when there is one. Run in a full clone with the series checked out. +# a newer upstream tag of the followed channel when there is one (FORK_FOLLOW_CHANNEL, nightly by +# default, or stable; fork-channel.sh). Run in a full clone with the series checked out. # It pushes and publishes nothing itself. Tests: fork-follow.test.sh. # -# Environment: UPSTREAM (owner/name), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN -# (git remote or URL of this fork, default origin), RUN_SHA (the commit this run's workflow files -# come from, default HEAD), REPOSITORY (owner/name of this fork, for the advice in reports), REPORT -# (file that receives the reason when the run cannot go on). +# Nightly: the series may sit on a commit of upstream's default branch that no nightly tag marks +# (it was made from that branch). Its base is then that commit, and the plan rebases it onto the +# newest nightly, which must contain it; while no nightly does, the plan refuses and waits. +# +# Environment: FORK_FOLLOW_CHANNEL, UPSTREAM (owner/name), UPSTREAM_URL (git URL of upstream, +# default GitHub), ORIGIN (git remote or URL of this fork, default origin), RUN_SHA (the commit +# this run's workflow files come from, default HEAD), REPOSITORY (owner/name of this fork, for the +# advice in reports), REPORT (file that receives the reason when the run cannot go on). # # Output, key=value lines to $GITHUB_OUTPUT (stdout without it): # action none the series head is the newest fork release of its base already @@ -68,24 +73,25 @@ on_error() { } trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR -stable_re='^v[0-9]+\.[0-9]+\.[0-9]+$' -fork_re='^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' head="$(git rev-parse HEAD)" run_sha="${RUN_SHA:-$head}" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/fork-channel.sh" +fork_channel_init || fail "FORK_FOLLOW_CHANNEL is '$FORK_FOLLOW_CHANNEL'; it must be nightly or stable. Nothing was planned." # Read-only fetches into refs this run alone uses: upstream's tags and default branch, and this -# fork's tags (its releases). -git fetch --quiet --no-tags "$upstream_url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" \ +# fork's tags (its releases). Pruned, so a tag deleted since an earlier fetch here is gone too. +git fetch --quiet --prune --no-tags "$upstream_url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" \ > "$logs/fetch.log" 2>&1 \ || fail "Could not fetch the tags and default branch of $UPSTREAM ($upstream_url):" '```' "$(tail -n 20 "$logs/fetch.log")" '```' -git fetch --quiet --no-tags "$ORIGIN" "+refs/tags/v*:refs/fork-tags/v*" > "$logs/fetch.log" 2>&1 \ +git fetch --quiet --prune --no-tags "$ORIGIN" "+refs/tags/v*:refs/fork-tags/v*" > "$logs/fetch.log" 2>&1 \ || fail "Could not fetch the tags of this fork ($ORIGIN):" '```' "$(tail -n 20 "$logs/fetch.log")" '```' -upstream_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags | grep -E "$stable_re" | sort -rV || true)" -fork_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/fork-tags | grep -E "$fork_re" | sort -rV || true)" +# Newest first by version (fork-channel.sh), not by name or by when a tag was made. +upstream_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags | { grep -E "$channel_re" || true; } | newest_first)" +fork_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/fork-tags | { grep -E "$fork_re" || true; } | newest_first)" target_tag="$(sed -n 1p <<< "$upstream_tags")" -[[ -n "$target_tag" ]] || fail "$UPSTREAM has no stable release tag (vX.Y.Z)." +[[ -n "$target_tag" ]] || fail "$UPSTREAM has no $channel release tag ($channel_form)." up() { git rev-parse "refs/upstream-tags/$1^{commit}"; } # The newest fork release in the series' history names the upstream tag the series was built on. @@ -99,7 +105,7 @@ for tag in $fork_tags; do fi done if [[ -n "$last_release" ]]; then - built_on="${last_release%-fork.*}" + built_on="${last_release%[-.]fork.*}" if ! git rev-parse --quiet --verify "refs/upstream-tags/$built_on^{commit}" > /dev/null; then fail "$UPSTREAM no longer has the tag $built_on, which the series was built on (fork release $last_release is in its history)." \ "Upstream deleted or renamed that release. Decide by hand which upstream release the series should sit on; the series was left as it is." @@ -110,7 +116,7 @@ if [[ -n "$last_release" ]]; then fi fi -# The series sits on the newest stable upstream tag it contains. +# The series sits on the newest upstream tag of the channel it contains. base_tag="" for tag in $upstream_tags; do if git merge-base --is-ancestor "$(up "$tag")" HEAD; then @@ -119,7 +125,17 @@ for tag in $upstream_tags; do break fi done -[[ -n "$base_tag" ]] || fail "The series ($head) contains no stable $UPSTREAM tag." +# Nightly: or on a later commit of upstream's default branch that no nightly tag marks. Its base is +# then that commit (base_tag names it by its short hash), and only a rebase can move it onto a tag. +untagged="" +if [[ "$channel" == nightly ]] && fork_point="$(git merge-base HEAD refs/upstream-head)" \ + && [[ "$fork_point" != "${base:-}" ]] \ + && { [[ -z "$base_tag" ]] || git merge-base --is-ancestor "$base" "$fork_point"; }; then + untagged=1 + base="$fork_point" + base_tag="${base:0:12}" +fi +[[ -n "$base_tag" ]] || fail "The series ($head) contains no $channel $UPSTREAM tag." # Every commit between that tag and the series head must be the fork's own. Upstream commits there # mean the branch is not a patch series (for example it still equals upstream's default branch). @@ -217,10 +233,11 @@ refuse_outside_workflows() { # refuse_outside_workflows UPSTREAM_BASE UPSTREAM_T "or, to keep one, rebase onto $3 by hand (see the conflict advice in fork-follow.yml)." } -# The newest fork release of a tag: " ", or nothing. +# The newest fork release of a tag (-fork.N or .fork.N): " ", or nothing. latest_release() { - local last - last="$(grep -E "^${1//./\\.}-fork\.[0-9]+$" <<< "$fork_tags" | sort -V | tail -n1 || true)" + local last sep=- + [[ "$(fork_sep "$1")" != . ]] || sep='\.' + last="$(grep -E "^${1//./\\.}${sep}fork\.[0-9]+$" <<< "$fork_tags" | head -n1 || true)" [[ -z "$last" ]] || echo "$last $(git rev-parse "refs/fork-tags/$last^{commit}")" } @@ -312,7 +329,26 @@ stage_report() { # stage_report CANDIDATE TAG CHANGED "The next follow run then finds that release (and moves the series to it if it is not there yet)." > "$REPORT" } -if [[ "$target_tag" == "$base_tag" ]]; then +# Nightly: the newest nightly must contain the series' base. A nightly tag moved or added to an older +# commit would otherwise take the series backwards, and an untagged base newer than every nightly +# has no tag a release could name yet. +target="$(up "$target_tag")" +if [[ "$channel" == nightly ]] && ! git merge-base --is-ancestor "$base" "$target"; then + if [[ -n "$untagged" ]] && git merge-base --is-ancestor "$target" "$base"; then + fail "The series sits on $UPSTREAM commit $base_tag, which is newer than the newest nightly, $target_tag." \ + "A fork release names the upstream tag it sits on, so the series was left as it is; a run after the next nightly follows it." + fi + fail "$UPSTREAM's newest nightly, $target_tag (${target:0:12}), does not contain the series' base $base_tag (${base:0:12})." \ + "Upstream moved or deleted a nightly tag, or tagged another branch. The series was left as it is; decide by hand which nightly it should sit on." +fi +# Upstream cuts nightlies from its default branch. A nightly-shaped tag on another branch that +# contains the base would pass the check above and bring that branch's code to every install. +if [[ "$channel" == nightly ]] && ! git merge-base --is-ancestor "$target" refs/upstream-head; then + fail "$UPSTREAM's newest nightly, $target_tag (${target:0:12}), is not on its default branch, so it is not one of upstream's nightlies (or upstream rewrote that branch)." \ + "The series was left as it is; decide by hand which nightly it should sit on." +fi + +if [[ -z "$untagged" && "$target_tag" == "$base_tag" ]]; then refuse_upstream_fork_named "$base" "$base_tag" "$head" # The candidate here is the series head itself, so a listed file in it is refused, not deleted. kept="$(present_removed "$head")" @@ -347,7 +383,6 @@ fi # A newer upstream release: replay the series onto it. Patches upstream now has become empty and # are dropped. A stop whose conflicts are all on listed (deleted) workflow files is resolved by # deleting them: upstream edited or brought back a file the fork deletes. -target="$(up "$target_tag")" before="$(git log --format=%s "$base..HEAD")" as_fork() { git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "$@"; } rebasing() { [[ -d "$(git rev-parse --git-path rebase-merge)" || -d "$(git rev-parse --git-path rebase-apply)" ]]; } diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 2e6c9a1c3f221..55bb56bb68794 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -87,6 +87,8 @@ export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ FORK_PUSH_ENVIRONMENT +# The cases below use stable tags; the nightly section sets the channel itself. +export FORK_FOLLOW_CHANNEL=stable export FAKE_API="$root/api" @@ -528,6 +530,141 @@ plan check "plan: a new outside-started upstream workflow that the list names -> deleted, rebase" \ eval 'action_is rebase && ! in_candidate outside.yml' +# --- plan: the nightly channel (fork-channel.sh) ------------------------------------------------- +# Upstream: u1 (v0.1.9) - n1 (v0.1.10-nightly.20261112.999) - n2 (...20261112.1000) - n3 (untagged, +# upstream main). Run 1000 is newer than run 999, which a sort of the names puts first. +nightly_up() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + mkdir -p .github/workflows + echo "on: workflow_call" > .github/workflows/release-desktop.yml + echo base > shared.txt + git add -A && git commit -q -m u1 && git tag v0.1.9 + echo n1 > n1.txt && git add -A && git commit -q -m n1 && git tag v0.1.10-nightly.20261112.999 + echo n2 > n2.txt && git add -A && git commit -q -m n2 && git tag v0.1.10-nightly.20261112.1000 + echo n3 > n3.txt && git add -A && git commit -q -m n3 + git push -q https://github.com/up/t3code.git main --tags + ) > /dev/null 2>&1 +} +n4=v0.1.10-nightly.20261113.1004 +nightly_more() { # n4 (tagged $n4) and n5 (untagged upstream main) on top of n3 + up_release "$n4" 'echo n4 > n4.txt' + (cd "$root/upwork" && echo n5 > n5.txt && git add -A && git commit -q -m n5 \ + && git push -q https://github.com/up/t3code.git main) > /dev/null 2>&1 +} +upc() { git -C "$root/upwork" rev-parse "$1"; } +nplan() { FORK_FOLLOW_CHANNEL="$1" plan; } + +nightly_up +reset_api +new_fork v0.1.10-nightly.20261112.999 +nplan nightly +check "nightly: newest by version (run 1000 above run 999) -> rebase onto it" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.10-nightly.20261112.1000 && "$(out previous_tag)" == v0.1.10-nightly.20261112.999 ]]' +new_fork v0.1.9 +nplan stable +check "nightly: the stable channel ignores nightly tags -> same base v0.1.9, release" \ + eval 'action_is release && [[ "$(out base_tag)" == v0.1.9 ]]' +new_fork v0.1.10-nightly.20261112.999 +nplan beta +check "nightly: an unknown channel is refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "FORK_FOLLOW_CHANNEL is .beta." "$root/report.md"' + +# The fork was made from upstream main at an untagged commit (n3): no nightly contains it yet. +new_fork "$(upc main)" +nplan nightly +check "nightly: an untagged base newer than the newest nightly -> refused, waits for the next" \ + eval '[[ -z "$(out action)" ]] && has "newer than the newest nightly, v0.1.10-nightly.20261112.1000" "$root/report.md"' +n3="$(upc main)" +nightly_more +new_fork "$n3" +before="$(work_head)" +(cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" env -u FORK_FOLLOW_CHANNEL bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "nightly: the default channel; the jump from the untagged base onto the first nightly above it" \ + eval 'action_is rebase && [[ "$(out base_tag)" == "$n4" && "$(out previous_tag)" == "${n3:0:12}" && "$(out previous_head)" == "$before" ]]' +check "nightly: the rebased candidate holds the fork's patch on the nightly" \ + eval 'git -C "$root/work" cat-file -e "$(out candidate):feature.txt" && git -C "$root/work" merge-base --is-ancestor "$(upc "$n4")" "$(out candidate)"' + +# Same nightly: release, then none once .fork.1 is the head. +new_fork "$n4" +nplan nightly +check "nightly: same nightly, no fork release yet -> release" eval 'action_is release && [[ "$(out base_tag)" == "$n4" ]]' +fork_tag "$n4.fork.1" +nplan nightly +check "nightly: the head is .fork.1 -> none, naming it" eval 'action_is none && [[ "$(out released_tag)" == "$n4.fork.1" ]]' +fork_commit "fork: more" 'echo more > more.txt' +nplan nightly +check "nightly: a new fork commit on the same nightly -> release" action_is release + +# Upstream deletes or moves the nightly the released series sits on. +git -C "$root/gh/up/t3code.git" tag -d "$n4" > /dev/null +nplan nightly +check "nightly: upstream deleted the nightly the series was built on -> named, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "no longer has the tag $n4" "$root/report.md"' +(cd "$root/upwork" && git checkout -q -b moved "$n3" && echo again > n4.txt && git add -A && git commit -q -m n4b \ + && git tag -f "$n4" && git push -q --force https://github.com/up/t3code.git "refs/tags/$n4") > /dev/null 2>&1 +nplan nightly +check "nightly: upstream moved the nightly the series was built on -> named" has "moved the tag $n4" "$root/report.md" + +# A newest nightly that does not contain the series' base (tagged on another line). +nightly_up +nightly_more +(cd "$root/upwork" && git checkout -q -b side v0.1.10-nightly.20261112.1000 && echo side > side.txt && git add -A \ + && git commit -q -m side && git tag v0.1.10-nightly.20261114.1010 \ + && git push -q https://github.com/up/t3code.git refs/tags/v0.1.10-nightly.20261114.1010) > /dev/null 2>&1 +new_fork "$n4" +before="$(work_head)" +nplan nightly +check "nightly: a newest nightly that does not contain the base -> refused, the series left as it is" \ + eval '[[ -z "$(out action)" ]] && has "does not contain the series. base $n4" "$root/report.md" && [[ "$(work_head)" == "$before" ]]' + +# A newest nightly tagged on another upstream branch that does contain the base: not one of +# upstream's nightlies (they are cut from its default branch). +nightly_up +nightly_more +(cd "$root/upwork" && git checkout -q -b side "$n4" && echo side > side.txt && git add -A \ + && git commit -q -m side && git tag v0.1.10-nightly.20261114.1010 \ + && git push -q https://github.com/up/t3code.git refs/tags/v0.1.10-nightly.20261114.1010) > /dev/null 2>&1 +new_fork "$n4" +before="$(work_head)" +nplan nightly +check "nightly: a newest nightly off upstream's default branch that contains the base -> refused, left as it is" \ + eval '[[ -z "$(out action)" ]] && has "v0.1.10-nightly.20261114.1010 (.*), is not on its default branch" "$root/report.md" && [[ "$(work_head)" == "$before" ]]' + +# Upstream rewrote main past the series' nightly: main restarts from nightly 1000 without n3 and +# n4, and a newer nightly is tagged on the new line. The merge-base with upstream main (nightly +# 1000) must not become an untagged base: n3 and n4, gone from upstream, would then count as the +# fork's own commits and be replayed into the next release. +nightly_up +nightly_more +new_fork "$n4" +(cd "$root/upwork" && git checkout -q -B main v0.1.10-nightly.20261112.1000 && echo n3b > n3b.txt && git add -A \ + && git commit -q -m n3b && git tag v0.1.10-nightly.20261114.1006 \ + && git push -q --force https://github.com/up/t3code.git main refs/tags/v0.1.10-nightly.20261114.1006) > /dev/null 2>&1 +before="$(work_head)" +nplan nightly +check "nightly: upstream rewrote main past the series' nightly -> refused, nothing replayed" \ + eval '[[ -z "$(out action)" && -z "$(out candidate)" ]] && has "does not contain the series. base $n4" "$root/report.md" \ + && [[ "$(work_head)" == "$before" && ! -d "$root/work/.git/rebase-merge" ]]' + +# A published .fork.1 of the rebased files: advance to it. +nightly_up +nightly_more +new_fork v0.1.10-nightly.20261112.1000 +nplan nightly +released="$(out candidate)" +work git push -q origin "$released:refs/heads/fork-follow/candidate" +fork_tag "$n4.fork.1" "$released" +work git checkout -q main && work git reset -q --hard "$(series)" +nplan nightly +check "nightly: a published .fork.1 of the rebased files -> advance to it" \ + eval 'action_is advance && [[ "$(out candidate)" == "$released" && "$(out released_tag)" == "$n4.fork.1" ]]' + # --- pushes: fork-follow-push.sh ------------------------------------------------------------- new_upstream new_fork v0.1.10 @@ -731,6 +868,15 @@ report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=success ADVANCE_RESULT=f BASE_TAG=v0.1.10 PREVIOUS_TAG=v0.1.9 PREVIOUS_HEAD="$prev" CANDIDATE="$cand" check "report: published but not moved -> says both, never 'nothing was published'" \ eval 'failed_run && has "v0.1.10-fork.2 \*\*published\*\*" "$root/summary.md" && has "not moved" "$root/summary.md" && hasnt -i "nothing was published" "$root/summary.md"' +work git commit -q --allow-empty -m "fork: rebased on a nightly" +ncand="$(work_head)" +fork_tag v0.1.10-nightly.20261113.1004.fork.1 "$ncand" +echo '{"draft":false}' > "$root/api/release-v0.1.10-nightly.20261113.1004.fork.1" +report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=success ADVANCE_RESULT=failure \ + BASE_TAG=v0.1.10-nightly.20261113.1004 PREVIOUS_TAG=v0.1.10-nightly.20261112.1000 PREVIOUS_HEAD="$prev" CANDIDATE="$ncand" +check "report: a published .fork.1 at the candidate is named as published" \ + has "v0.1.10-nightly.20261113.1004.fork.1 \*\*published\*\*" "$root/summary.md" +work git reset -q --hard "$cand" rm -rf "$root/reports" && mkdir -p "$root/reports" report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=failure ADVANCE_RESULT=skipped \ BASE_TAG=v0.1.11 PREVIOUS_HEAD="$prev" CANDIDATE="$(git -C "$root/work" rev-parse HEAD~1)" diff --git a/.github/scripts/fork-release.sh b/.github/scripts/fork-release.sh index f0ff3eee17d38..9077f90354bec 100755 --- a/.github/scripts/fork-release.sh +++ b/.github/scripts/fork-release.sh @@ -2,20 +2,25 @@ # The guards of fork-release.yml, one subcommand each, so they can be tested without a run # (fork-release.test.sh). Every refusal exits non-zero with a ::error:: line; nothing falls back. # -# resolve Preflight: picks base tag, version and tag for HEAD (key=value lines). +# resolve Preflight: picks base tag, version and tag for HEAD (key=value lines): +# -fork.N on a stable base, .fork.N on a nightly one. # remove-drafts TAG Refuses a published TAG, then deletes drafts with TAG left by earlier runs. -# publish ID TAG VERSION Refuses a published TAG or a published release that is not below -# VERSION, then publishes draft ID as the latest release. +# publish ID TAG VERSION Refuses a published TAG, a published release that is not below +# VERSION, or an upstream base that is no longer released (a nightly +# withdrawn during the build), then publishes draft ID as the latest release. # check-bundle DIR The server bundle DIR (apps/server/dist) outside DIR/client must name # REPOSITORY as its release repository. # check-archives DIR The `t3` program of every CLI archive in DIR must name REPOSITORY. # -# Environment: REPOSITORY (owner/name of this fork; GITHUB_REPOSITORY in a run), UPSTREAM -# (owner/name, resolve looks it up), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN -# (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout. +# Environment: FORK_FOLLOW_CHANNEL (nightly or stable, default nightly: which upstream tags a base +# may be; fork-channel.sh), REPOSITORY (owner/name of this fork; GITHUB_REPOSITORY in a run), UPSTREAM +# (owner/name; this fork's parent when unset), UPSTREAM_URL (git URL of upstream, default GitHub), +# ORIGIN (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout. set -euo pipefail # Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). . "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" +# The channel, the fork's release names and version_gt. +. "$(dirname "${BASH_SOURCE[0]}")/fork-channel.sh" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-origin}" @@ -27,23 +32,11 @@ die() { } stable_version_re='^v[0-9]+\.[0-9]+\.[0-9]+$' -fork_version_re='^v?[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' - -# version_gt A B: A sorts above B. Takes X.Y.Z or X.Y.Z-fork.N (leading v allowed). A fork -# release of X.Y.Z sorts above X.Y.Z itself here, which is what this fork's ordering needs. -version_gt() { - local a="${1#v}" b="${2#v}" i - local -a x y - IFS=. read -r -a x <<< "${a/-fork./.}" - IFS=. read -r -a y <<< "${b/-fork./.}" - for i in 0 1 2 3; do - if (( 10#${x[i]:-0} != 10#${y[i]:-0} )); then - (( 10#${x[i]:-0} > 10#${y[i]:-0} )) - return - fi - done - return 1 -} +# A fork version, with or without the leading v (fork_re in fork-channel.sh). +fork_version_re="^v?${fork_re#^v}" +# The updaters' own rule for a release on another track (cliReleaseChannelOf): the version ends in +# -nightly.YYYYMMDD.N or -preview.YYYYMMDD.N. A fork release of a nightly does not, so it counts. +other_track_re='^v[^-+]+-(nightly|preview)\.[0-9]{8}\.[0-9]+$' # Prints the releases of this fork through a jq filter; a failed listing stops everything. releases() { @@ -67,66 +60,114 @@ refuse_published() { fi } +upstream_repo() { # prints UPSTREAM, or this fork's parent + local upstream="${UPSTREAM:-}" + if [[ -z "$upstream" ]]; then + upstream="$(gh api "repos/$REPOSITORY" --jq '.parent.full_name // empty')" + [[ -n "$upstream" ]] || die "$REPOSITORY is not a fork, so there is no upstream release to build on." + fi + printf '%s\n' "$upstream" +} + +# The upstream tag a fork version is built on: X.Y.Z-fork.N on vX.Y.Z, X.Y.Z-nightly.D.R.fork.N on +# vX.Y.Z-nightly.D.R. +fork_base_tag() { local v="v${1#v}"; if [[ "$v" == *-nightly.* ]]; then printf '%s\n' "${v%.fork.*}"; else printf '%s\n' "${v%-fork.*}"; fi; } + +# Only released upstream versions, checked by preflight and again right before publishing (a +# nightly upstream withdraws during the build must not reach auto-updating installs). +# Stable tag: never above the release upstream marks latest. Nightly tag: upstream published a +# release of that tag itself (a nightly is a pre-release, which "latest" never names; a tag whose +# release is missing or still a draft was not released; this fork's token cannot see another +# repository's drafts, so a draft reads as missing). +require_released() { # require_released UPSTREAM TAG + local upstream="$1" tag="$2" draft upstream_latest + if [[ "$tag" == *-nightly.* ]]; then + draft="$(gh api "repos/$upstream/releases/tags/$tag" --jq '.draft')" \ + || die "Could not read $upstream's release of $tag (it has none, or the lookup failed). Only released upstream versions are built and published." + [[ "$draft" == false ]] || die "$upstream's release of $tag is not published (draft: $draft). Only released upstream versions are built and published." + else + upstream_latest="$(gh api "repos/$upstream/releases/latest" --jq '.tag_name')" + [[ "$upstream_latest" =~ $stable_version_re ]] \ + || die "$upstream's latest release is '$upstream_latest', not a stable vX.Y.Z tag." + if version_gt "$tag" "$upstream_latest"; then + die "$tag sorts above $upstream's latest release $upstream_latest. Only released upstream versions are built and published." + fi + fi +} + # The server's updater (packages/shared/src/cliRelease.ts newestCliReleaseVersion) takes the # newest-created non-draft release on its channel, not the highest version. So every new release # must be above every published one, or `t3 update` would see a downgrade and stall until a higher -# build exists. Stable channel = any vX.Y.Z[-...] tag except -nightly./-preview. builds. -refuse_older() { - local version="$1" other published +# build exists. Stable channel = any vX.Y.Z[-...] tag except upstream-style nightly and preview builds. +# +# A higher version is not newer code for one pair: upstream names a nightly after the release it +# leads up to, so vX.Y.Z-nightly.D.R (older than vX.Y.Z) still sorts above a published X.Y.Z-fork.N. +# Its fork build would install older upstream code over the stable one, which may lack migrations +# that build already ran. So a nightly build of the same X.Y.Z as a published stable fork release +# must contain that release's upstream tag (GitHub's compare of the two upstream tags). +refuse_older() { # refuse_older VERSION UPSTREAM + local version="$1" upstream="$2" other published mine status + mine="v${version#v}" published="$(releases '.[] | select(.draft | not) | .tag_name')" for other in $published; do [[ "$other" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]] || continue - [[ ! "$other" =~ -(nightly|preview)\. ]] || continue + [[ ! "$other" =~ $other_track_re ]] || continue [[ "$other" =~ $stable_version_re || "$other" =~ $fork_version_re ]] \ - || die "Published release $other is on the server's stable channel but has no X.Y.Z or X.Y.Z-fork.N version, so $version cannot be ordered against it; nothing was changed." + || die "Published release $other is on the server's stable channel but has no X.Y.Z, X.Y.Z-fork.N or X.Y.Z-nightly.D.N.fork.N version, so $version cannot be ordered against it; nothing was changed." version_gt "$version" "$other" \ || die "$other is published and not below $version. The server updates to the newest-created release, so publishing $version would show every server a downgrade. Build a newer upstream release instead; to go back, install the older release with t3 update --version X --allow-downgrade. Nothing was changed." + if [[ "$mine" == *-nightly.* && "$other" != *-nightly.* && "$other" == *-fork.* \ + && "${mine%%-*}" == "${other%%-*}" ]]; then + status="$(gh api "repos/$upstream/compare/$(fork_base_tag "$other")...$(fork_base_tag "$mine")" --jq '.status')" \ + || die "Could not compare $upstream $(fork_base_tag "$other") with $(fork_base_tag "$mine"), so $version cannot be ordered against the published $other; nothing was changed." + [[ "$status" == ahead || "$status" == identical ]] \ + || die "$other is published and built on $upstream $(fork_base_tag "$other"), which $(fork_base_tag "$mine") does not contain ($status). Upstream names a nightly after the release it leads up to, so $version sorts above $other but holds older upstream code; every install would update to it. Build a nightly that contains $(fork_base_tag "$other") instead. Nothing was changed." + fi done } cmd_resolve() { - local upstream="${UPSTREAM:-}" - if [[ -z "$upstream" ]]; then - upstream="$(gh api "repos/$REPOSITORY" --jq '.parent.full_name // empty')" - [[ -n "$upstream" ]] || die "$REPOSITORY is not a fork, so there is no upstream release to build on." - fi + local upstream + upstream="$(upstream_repo)" local url="${UPSTREAM_URL:-https://github.com/$upstream.git}" - local head + local head channel channel_re channel_form + fork_channel_init || die "FORK_FOLLOW_CHANNEL is '$FORK_FOLLOW_CHANNEL'; it must be nightly or stable." head="$(git rev-parse HEAD)" # Read-only fetch of upstream's tags and default branch into refs this run alone uses. - git fetch --quiet --no-tags "$url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" + git fetch --quiet --prune --no-tags "$url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" # Lists are read into variables first: a failure inside <(...) would go unnoticed. local refs tag local -a contained=() refs="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags)" - for tag in $(grep -E "$stable_version_re" <<< "$refs" | sort -rV || true); do + refs="$({ grep -E "$channel_re" <<< "$refs" || true; } | newest_first)" + for tag in $refs; do if git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" HEAD; then contained+=("$tag") fi done - (( ${#contained[@]} > 0 )) || die "$head contains no stable $upstream release tag." + (( ${#contained[@]} > 0 )) || die "$head contains no $channel $upstream release tag ($channel_form)." local base_tag="${contained[0]}" local base base="$(git rev-parse "refs/upstream-tags/$base_tag^{commit}")" - # The base must be the newest stable tag in the series' history, not just the highest name: a + # The base must be the newest tag of the channel in the series' history, not just the highest: a # stray tag on an old commit (say v1.0.0) would otherwise outrank every later real release. for tag in "${contained[@]:1}"; do git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" "$base" \ - || die "$base_tag sorts highest but is not the newest stable $upstream tag in this history ($tag is not part of it). Delete or fix the stray tag; nothing was built." + || die "$base_tag sorts highest but is not the newest $channel $upstream tag in this history ($tag is not part of it). Delete or fix the stray tag; nothing was built." done - # Upstream's own ordering: never above the release upstream marks latest. - local upstream_latest - upstream_latest="$(gh api "repos/$upstream/releases/latest" --jq '.tag_name')" - [[ "$upstream_latest" =~ $stable_version_re ]] \ - || die "$upstream's latest release is '$upstream_latest', not a stable vX.Y.Z tag." - if version_gt "$base_tag" "$upstream_latest"; then - die "$base_tag sorts above $upstream's latest release $upstream_latest. Only released upstream versions are built." + # Nightly: upstream cuts nightlies from its default branch. A nightly-shaped tag on another + # branch (which the checks above accept when it is the newest one in this history) is not one. + if [[ "$channel" == nightly ]]; then + git merge-base --is-ancestor "$base" refs/upstream-head \ + || die "$base_tag is not on $upstream's default branch, so it is not one of upstream's nightlies (or upstream rewrote that branch). Only nightlies of the default branch are built; nothing was built." fi + require_released "$upstream" "$base_tag" + # Releases only: every commit after the base tag must be the fork's own. Upstream commits there # are unreleased upstream code (for example a branch that still equals upstream's default branch). local total own @@ -136,11 +177,13 @@ cmd_resolve() { die "$head holds $((total - own)) unreleased $upstream commits after $base_tag. Build a branch of $base_tag plus the fork's own commits." fi - local version="${base_tag#v}" last - last="$(git ls-remote --tags --refs "$ORIGIN" "v$version-fork.*" \ - | sed -n "s|.*refs/tags/v$version-fork\.\([0-9]\+\)$|\1|p" | sort -n | tail -n1)" - version="$version-fork.$(( ${last:-0} + 1 ))" - refuse_older "$version" + local version="${base_tag#v}" sep sep_re=- last + sep="$(fork_sep "$base_tag")" + [[ "$sep" != . ]] || sep_re='\.' + last="$(git ls-remote --tags --refs "$ORIGIN" "v${version}${sep}fork.*" \ + | sed -n "s|.*refs/tags/v${version//./\\.}${sep_re}fork\.\([0-9]\+\)$|\1|p" | sort -n | tail -n1)" + version="${version}${sep}fork.$(( ${last:-0} + 1 ))" + refuse_older "$version" "$upstream" { echo "ref=$head" echo "upstream=$upstream" @@ -168,9 +211,14 @@ cmd_publish() { local id="${1:?release id}" tag="${2:?tag}" version="${3:?version}" [[ "$version" =~ $fork_version_re ]] || die "'$version' is not a fork version." refuse_published "$tag" + local upstream + upstream="$(upstream_repo)" # Checked again here: a release published since preflight must not end up above this one. After # this check VERSION is the highest release, so the server and the desktop updater agree on it. - refuse_older "$version" + refuse_older "$version" "$upstream" + # And upstream must still have released the base: a nightly withdrawn during the build (release + # deleted or set back to draft) is not published to auto-updating installs. + require_released "$upstream" "$(fork_base_tag "$version")" gh api -X PATCH "repos/$REPOSITORY/releases/$id" -F draft=false -f make_latest=true > /dev/null echo "Published $tag (latest: true)." { diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh index 0ae10f0da7f01..f1be8599b82da 100755 --- a/.github/scripts/fork-release.test.sh +++ b/.github/scripts/fork-release.test.sh @@ -31,6 +31,19 @@ done case "$method $path" in "GET repos/me/t3code") body='{"parent":{"full_name":"up/t3code"}}' ;; "GET repos/up/t3code/releases/latest") body="{\"tag_name\":\"$(cat "$api/upstream_latest")\"}" ;; + "GET repos/up/t3code/releases/tags/"*) + [[ -f "$api/up-release-${path##*/}" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + body="$(cat "$api/up-release-${path##*/}")" ;; + "GET repos/up/t3code/compare/"*) # A...B: how upstream tag B relates to tag A, from the local upstream + range="${path#*/compare/}" up_git="$api/../gh/up/t3code.git" + a="$(git -C "$up_git" rev-parse -q --verify "refs/tags/${range%%...*}^{commit}")" \ + && b="$(git -C "$up_git" rev-parse -q --verify "refs/tags/${range#*...}^{commit}")" \ + || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + if [[ "$a" == "$b" ]]; then status=identical + elif git -C "$up_git" merge-base --is-ancestor "$a" "$b"; then status=ahead + elif git -C "$up_git" merge-base --is-ancestor "$b" "$a"; then status=behind + else status=diverged; fi + body="{\"status\":\"$status\"}" ;; "GET repos/me/t3code/releases") [[ ! -e "$api/fail_list" ]] || { echo "HTTP 502: Bad Gateway" >&2; exit 1; } body="$(cat "$api/releases.json")" ;; @@ -58,6 +71,8 @@ git config --global advice.detachedHead false git config --global url."$root/gh/".insteadOf https://github.com/ export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN +# The cases below use stable tags; the nightly section sets the channel itself. +export FORK_FOLLOW_CHANNEL=stable export FAKE_API="$root/api" @@ -151,6 +166,162 @@ expect_refused "resolve: refuses a build below a published release (older ref af echo '[{"id":5,"draft":true,"prerelease":false,"tag_name":"v0.1.10-fork.3"}]' > "$root/api/releases.json" expect_resolved "resolve: a higher draft does not block (updaters skip drafts)" 0.1.9-fork.1 +# --- the nightly channel (fork-channel.sh) ---------------------------------------------------- +# Upstream: u1 (v0.1.9) - n1 (v0.1.10-nightly.20261113.4630) - u3 (untagged, upstream main). +nt=v0.1.10-nightly.20261113.4630 +nightly_upstream() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + git commit -q --allow-empty -m u1 && git tag v0.1.9 + git commit -q --allow-empty -m n1 && git tag "$nt" + git commit -q --allow-empty -m u3 + git push -q https://github.com/up/t3code.git main --tags + ) +} +up_released() { echo "{\"tag_name\":\"$1\",\"draft\":${2:-false},\"prerelease\":true}" > "$root/api/up-release-$1"; } +nightly_upstream +reset_api +up_released "$nt" +new_fork "$nt" +export FORK_FOLLOW_CHANNEL=nightly +expect_resolved "nightly: a published nightly plus a fork commit is .fork.1" "${nt#v}.fork.1" +grep -qx "tag=$nt.fork.1" "$root/out" && pass "nightly: its tag is v.fork.1" || fail "nightly: its tag is v.fork.1" +fork_tag "$nt.fork.1" +expect_resolved "nightly: the next build counts the .fork.N tags" "${nt#v}.fork.2" +unset FORK_FOLLOW_CHANNEL +expect_resolved "nightly: the default channel is nightly" "${nt#v}.fork.2" +export FORK_FOLLOW_CHANNEL=nightly +up_released "$nt" true +expect_refused "nightly: a nightly whose upstream release is a draft is refused" "not published" "$root/work" resolve +rm "$root/api/up-release-$nt" +expect_refused "nightly: a nightly with no upstream release is refused" "Could not read up/t3code's release of $nt" "$root/work" resolve +up_released "$nt" +new_fork main +expect_refused "nightly: untagged upstream commits after the nightly are refused" "unreleased" "$root/work" resolve +new_fork v0.1.9 +expect_refused "nightly: a series with no nightly tag is refused" "contains no nightly" "$root/work" resolve +FORK_FOLLOW_CHANNEL=beta expect_refused "nightly: an unknown channel is refused" "FORK_FOLLOW_CHANNEL is 'beta'" "$root/work" resolve + +# Ordering: a published fork release of a nightly is on the updaters' stable track, so it blocks +# a lower build; an upstream-style nightly name (another track) does not. +export FORK_FOLLOW_CHANNEL=stable +reset_api v0.1.9 +echo "[{\"id\":5,\"draft\":false,\"prerelease\":false,\"tag_name\":\"$nt.fork.1\"}]" > "$root/api/releases.json" +expect_refused "nightly: a published .fork.1 blocks a lower build" "$nt.fork.1 is published and not below 0.1.9-fork.1" "$root/work" resolve +echo '[{"id":5,"draft":false,"prerelease":true,"tag_name":"v0.1.11-nightly.20261114.1"}]' > "$root/api/releases.json" +expect_resolved "nightly: an upstream-style nightly release (another track) does not block" 0.1.9-fork.1 +export FORK_FOLLOW_CHANNEL=nightly +nightly_upstream +reset_api +up_released "$nt" +new_fork "$nt" +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10-fork.4"}]' > "$root/api/releases.json" +expect_refused "nightly: a published v0.1.10-fork.4 whose upstream v0.1.10 is gone cannot be ordered -> refused" \ + "Could not compare up/t3code v0.1.10 with $nt" "$root/work" resolve +(cd "$root/upwork" && git tag v0.1.10 "$nt" && git push -q https://github.com/up/t3code.git v0.1.10) > /dev/null 2>&1 +expect_resolved "nightly: .fork.1 is above a stable fork release of the same X.Y.Z it contains" "${nt#v}.fork.1" +echo "[{\"id\":5,\"draft\":false,\"prerelease\":false,\"tag_name\":\"v0.1.10-nightly.20261113.4631.fork.1\"}]" > "$root/api/releases.json" +expect_refused "nightly: a newer published nightly release blocks an older one" "4631.fork.1 is published and not below" "$root/work" resolve +# A plain X.Y.Z sorts above every pre-release of it, in the app as in semver (compareSemverVersions). +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10"}]' > "$root/api/releases.json" +expect_refused "nightly: a published plain v0.1.10 blocks a <0.1.10 nightly>.fork.1 (the app ranks it lower)" \ + "v0.1.10 is published and not below ${nt#v}.fork.1" "$root/work" resolve + +# A nightly tag on another upstream branch than the default one, even one that is the newest in +# the series' history, is not one of upstream's nightlies. +side=v0.1.10-nightly.20261114.4700 +(cd "$root/upwork" && git checkout -q -b side "$nt" && git commit -q --allow-empty -m side && git tag "$side" \ + && git push -q https://github.com/up/t3code.git "refs/tags/$side" && git checkout -q main) > /dev/null 2>&1 +echo '[]' > "$root/api/releases.json" +up_released "$side" +new_fork "$side" +expect_refused "nightly: a nightly tag that is not on upstream's default branch is refused" \ + "$side is not on up/t3code's default branch" "$root/work" resolve + +# Upstream names a nightly after the release it leads up to: v0.1.9-nightly.20261109.4443 is older +# than v0.1.9, yet its fork build sorts above a published v0.1.9-fork.1. Nightly 4515 is the +# v0.1.9 commit itself. Upstream: o1 (4443) - s1 (v0.1.9 = 4515) - main. +old_n=v0.1.9-nightly.20261109.4443 same_n=v0.1.9-nightly.20261110.4515 +rm -rf "$root/gh" "$root/upwork" +mkdir -p "$root/gh/up" "$root/gh/me" +git init -q --bare "$root/gh/up/t3code.git" +git init -q --bare "$root/gh/me/t3code.git" +git init -q "$root/upwork" +(cd "$root/upwork" && git commit -q --allow-empty -m o1 && git tag "$old_n" \ + && git commit -q --allow-empty -m s1 && git tag v0.1.9 && git tag "$same_n" \ + && git commit -q --allow-empty -m u3 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +reset_api v0.1.9 +up_released "$old_n" +up_released "$same_n" +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.1"}]' > "$root/api/releases.json" +new_fork "$old_n" +expect_refused "nightly: a build of a nightly older than a published v0.1.9-fork.1's v0.1.9 is refused" \ + "v0.1.9-fork.1 is published and built on up/t3code v0.1.9, which $old_n does not contain" "$root/work" resolve +new_fork "$same_n" +expect_resolved "nightly: a nightly that contains v0.1.9 goes forward from v0.1.9-fork.1" "${same_n#v}.fork.1" +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"'"$old_n"'.fork.1"}]' > "$root/api/releases.json" +expect_refused "nightly: publish re-checks that ancestry (v0.1.9-fork.1 published during the build)" \ + "which $old_n does not contain" "$root/work" publish 9 "$old_n.fork.1" "${old_n#v}.fork.1" +[[ ! -e "$root/api/writes" ]] || fail "nightly: nothing written for an older nightly" + +# Publish re-checks upstream's release of the nightly: one withdrawn during the build (release +# deleted, or set back to draft) is not published to auto-updating installs. +echo '[{"id":9,"draft":true,"prerelease":false,"tag_name":"'"$same_n"'.fork.1"}]' > "$root/api/releases.json" +rm -f "$root/api/writes" +rm "$root/api/up-release-$same_n" +expect_refused "nightly: publish refuses a nightly whose upstream release was deleted during the build" \ + "Could not read up/t3code's release of $same_n" "$root/work" publish 9 "$same_n.fork.1" "${same_n#v}.fork.1" +[[ ! -e "$root/api/writes" ]] || fail "nightly: nothing written for a withdrawn nightly" +rm -f "$root/api/writes" +up_released "$same_n" true +expect_refused "nightly: publish refuses a nightly whose upstream release went back to draft" \ + "release of $same_n is not published" "$root/work" publish 9 "$same_n.fork.1" "${same_n#v}.fork.1" +[[ ! -e "$root/api/writes" ]] || fail "nightly: nothing written for a nightly back in draft" +rm -f "$root/api/writes" +up_released "$same_n" +if run "$root/work" publish 9 "$same_n.fork.1" "${same_n#v}.fork.1" \ + && grep -qx "patched 9 draft=false make_latest=true" "$root/api/writes"; then + pass "nightly: publish goes ahead while upstream's release of the nightly is published" +else + fail "nightly: publish goes ahead while upstream's release of the nightly is published" +fi +export FORK_FOLLOW_CHANNEL=stable + +# version_gt / newest_first (fork-channel.sh): numbers compare as numbers, forms rank as semver. +. "$(dirname "$script")/fork-channel.sh" +vcase() { # vcase A B WANT: version_gt A B exits WANT + local got=0 + version_gt "$1" "$2" || got=$? + [[ "$got" == "$3" ]] && pass "version_gt $1 $2 -> $3" || { : > "$root/out"; fail "version_gt $1 $2 -> $3 (got $got)"; } +} +vcase v0.1.10-nightly.20261112.1000 v0.1.10-nightly.20261112.999 0 +vcase v0.1.10-nightly.20261113.1 v0.1.10-nightly.20261112.9999 0 +vcase v0.1.10-nightly.20261113.4630.fork.1 v0.1.10-nightly.20261113.4630 0 +vcase v0.1.10-nightly.20261113.4630.fork.10 v0.1.10-nightly.20261113.4630.fork.9 0 +vcase v0.1.10-nightly.20261113.4630.fork.9 v0.1.10-nightly.20261113.4631 1 +vcase 0.1.10-nightly.20261113.1.fork.1 v0.1.10-fork.3 0 +# Plain X.Y.Z above every pre-release of it, as compareSemverVersions has it (checked with the app: +# -1 for the first two pairs, 1 for the next two). +vcase v0.1.10-fork.1 v0.1.10 1 +vcase v0.1.10-nightly.20261113.1.fork.1 v0.1.10 1 +vcase v0.1.10 v0.1.10-fork.1 0 +vcase v0.1.10 v0.1.10-nightly.20261113.4630.fork.1 0 +vcase v0.1.11 v0.1.10-nightly.20261113.4630.fork.1 0 +vcase v0.1.10 v0.1.10 1 +vcase v0.1.10-rc.1 v0.1.9 2 +got="$(printf '%s\n' v0.1.10-nightly.20261112.999 v0.1.10-nightly.20261112.1000 v0.1.9 v0.1.10-nightly.20261112.1000.fork.2 | newest_first | tr '\n' ' ')" +[[ "$got" == "v0.1.10-nightly.20261112.1000.fork.2 v0.1.10-nightly.20261112.1000 v0.1.10-nightly.20261112.999 v0.1.9 " ]] \ + && pass "newest_first orders by version, not by name" || { echo "$got" > "$root/out"; fail "newest_first orders by version, not by name"; } +printf 'v0.1.9\nv0.1.10-rc.1\n' | newest_first > /dev/null && fail "newest_first fails on a form it cannot order" \ + || pass "newest_first fails on a form it cannot order" +new_upstream + # --- remove-drafts ----------------------------------------------------------------------------- new_upstream reset_api diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 8761834658dbf..6fe7d2f64aeb5 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -1,10 +1,19 @@ name: Fork follow upstream -# Daily: keeps this fork's patch series on upstream's newest stable release and publishes it. +# Daily: keeps this fork's patch series on upstream's newest tag of one channel and publishes it. +# The channel is the variable FORK_FOLLOW_CHANNEL: nightly (the default; tags +# vX.Y.Z-nightly.YYYYMMDD.N, released as .fork.) or stable (vX.Y.Z, released as +# -fork.); .github/scripts/fork-channel.sh. Upstream cuts several nightlies a day: a run +# takes the newest by version (every nightly contains the ones before it), so once a day costs one +# rebase, test and build however many nightlies there were; start a run by hand for one sooner. +# Nightly also starts from a series on an untagged commit of upstream's default branch: it is +# rebased onto the newest nightly that contains that commit. Switching nightly -> stable is by +# hand (rebase onto a stable tag that contains the series' nightly), and the first stable release +# must be on a newer X.Y.Z than the last nightly one, or the release refuses it as a downgrade. # The plan (.github/scripts/fork-follow.sh) picks one action: # - none: the series head is already the newest fork release of its base. # - release: same base, but the series has commits no fork release has: test, build and publish. -# - rebase: upstream has a newer stable tag: rebase the series onto it, test, build, publish, then +# - rebase: upstream has a newer tag: rebase the series onto it, test, build, publish, then # move the series branch to the released commit (the old series is kept as a tag). # - advance: a published fork release already has the rebased files (an earlier run published it, # then could not move the branch): move the series branch to it, build nothing. @@ -17,10 +26,11 @@ name: Fork follow upstream # A missing or malformed list, and a new upstream workflow that someone outside the fork can start # (pull_request_target, workflow_run, issues ...; fork-follow.sh) and the list does not name, stop it. # A conflict, merge commits in the series, a schema change, upstream commits inside the series, a -# moved or deleted upstream tag, a red test, a failed build or push: the run fails, and the Report -# job's summary says why and what the repository holds now (branch heads, release tags, drafts), as -# read back from it. With Issues enabled it also opens or updates one issue, and closes it again -# on the next good run. +# moved or deleted upstream tag, a newest nightly that does not contain the series' base (moved +# backwards, or the series sits past it), a red test, a failed build or push: the run fails, and +# the Report job's summary says why and what the repository holds now (branch heads, release +# tags, drafts), as read back from it. With Issues enabled it also opens or updates one issue, and +# closes it again on the next good run. # # Upstream's workflows stay off: after each push, before the series branch moves, and at the end, # fork-workflows.sh disables every active workflow the series did not add (a fork-*.yml is always the @@ -33,7 +43,8 @@ name: Fork follow upstream # A re-run of failed jobs (or of one job) keeps the Guard job's earlier result, so the jobs with # the token are skipped unless the guard passed in this attempt: use "Re-run all jobs". # -# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN, only in the +# Settings: variables FORK_SERIES_BRANCH (default main) and FORK_FOLLOW_CHANNEL (nightly or stable, +# default nightly; fork-release.yml reads the same one). Secret FORK_PUSH_TOKEN, only in the # Environment fork-push, whose deployment branches are "Selected branches and tags" with the one # rule: branch . A repository secret could be read by a workflow file on any pushed # branch, and an Environment without that rule hands its secret to any branch. The Guard job checks @@ -206,6 +217,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RUN_SHA: ${{ github.sha }} + FORK_FOLLOW_CHANNEL: ${{ vars.FORK_FOLLOW_CHANNEL || 'nightly' }} REPORT: ${{ runner.temp }}/reports/plan.md run: | set -euo pipefail diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index d5bc8fb2d93d3..d33953692ec2f 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -1,9 +1,11 @@ name: Fork release -# Builds this fork's release `v-fork.`: upstream's tests, the Linux x64 server archive -# with SHA256SUMS, and the desktop apps (Linux x64, Windows x64, macOS arm64 and x64), all on -# GitHub-hosted runners and unsigned unless signing secrets exist. is the newest stable -# upstream tag the commit contains; counts the fork's earlier builds of it. +# Builds this fork's release `v.fork.` (nightly) or `v-fork.` (stable): +# upstream's tests, the Linux x64 server archive with SHA256SUMS, and the desktop apps (Linux x64, +# Windows x64, macOS arm64 and x64), all on GitHub-hosted runners and unsigned unless signing +# secrets exist. is the newest upstream tag of the channel (variable +# FORK_FOLLOW_CHANNEL, default nightly) the commit contains; counts the fork's earlier builds +# of it. Both names read as the app's stable track, so the builds use the stable feed and branding. # # Builds name this repository as their release repository (scripts/lib/release-repository.ts reads # T3CODE_DESKTOP_UPDATE_REPOSITORY, else GITHUB_REPOSITORY in Actions), so the server's update and @@ -135,12 +137,14 @@ jobs: python3 -c 'import yaml' 2> /dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } python3 .github/scripts/fork-release-workflow.test.py - # Refuses unreleased upstream commits, a stray upstream tag and a base above upstream's latest. + # Refuses unreleased upstream commits, a stray upstream tag, and a base upstream has not + # released (stable: above upstream's latest; nightly: no published release of that tag). - id: meta name: Resolve fork release version shell: bash env: GH_TOKEN: ${{ github.token }} + FORK_FOLLOW_CHANNEL: ${{ vars.FORK_FOLLOW_CHANNEL || 'nightly' }} run: bash .github/scripts/fork-release.sh resolve tests: @@ -599,13 +603,15 @@ jobs: release-assets/*.tar.gz release-assets/SHA256SUMS - # Updaters skip drafts, so nobody sees a release until every file above is uploaded. The tag - # and "nothing published is as high" checks run again right before; it is then made latest. + # Updaters skip drafts, so nobody sees a release until every file above is uploaded. The tag, + # "nothing published is as high" and "upstream still has the base released" checks run again + # right before; it is then made latest. - name: Publish the draft id: publish shell: bash env: GH_TOKEN: ${{ github.token }} + UPSTREAM: ${{ needs.preflight.outputs.upstream }} RELEASE_ID: ${{ steps.draft.outputs.id }} TAG: ${{ needs.preflight.outputs.tag }} VERSION: ${{ needs.preflight.outputs.version }} From cf160836393ffd1e1cb44b7ab8ba7f73f98ba382 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:10:35 +0100 Subject: [PATCH 11/27] feat(web): file links open remotely, full size, download from their menu (#17) * feat(web): file links open remotely, full size, download from their menu A file link in a chat reply only offered copy actions in its context menu when the environment runs on another machine, and touch screens had no menu at all. The menu now offers, wherever the client runs: - Open in files viewer, and Open full size (the panel maximized) - Open in at the linked line: on a remote environment this builds the editor's remote URL (e.g. vscode-remote ssh) from the existing SSH profile or the server's remote-open targets, the same host the header's Open in picker uses - Download to this device, through the environment's media asset URL - Copy relative path, full path (with line), full path without line A touch long press opens the same menu and suppresses the click that follows it. Plain click is unchanged. Co-Authored-By: Claude Opus 5.5 * fix(web): remote editor links keep their host; the long-press menu survives a late contextmenu Round 1 of the file link menu review. - A `..` (or `%2E%2E`) segment in a linked path reached the remote editor URL, where every URL parser removes it together with the host segment in front of it: `zed://ssh/me@devbox/../evil/x` opens `evil`. The builder now resolves dot segments (never above the root or a Windows drive) and returns nothing for a URL that does not parse back to itself. The browser opener and the desktop's external URL check refuse such a remote editor URL too. - Android (after its touch & hold delay) and Windows touch (on lift) send their own contextmenu after the 500 ms long-press timer has opened the menu, and the menu closed on it. The first contextmenu on the chip after a long-press open is now swallowed before the menu sees it; the next pointer or key press ends that wait. - After a long press only the touch's own click is suppressed, so keyboard Enter and the menu key work at once; the timer and the listener are cleared on unmount. - The download test now checks the fetched asset URL, the saved name and the bytes; the Zed and JetBrains tests say that their ssh links carry no line. Co-Authored-By: Claude Opus 5.5 * test: placeholder user in the remote editor URL tests Co-Authored-By: Claude Opus 5.5 * Test the long press without an ending click, the iOS release and a Zed dot host - Keyboard Enter and the menu key after a long press that ends with no click (the usual phone case); goes red without the detail-0 check or with the old fired guard back in the contextmenu handler. - A right-click and a new long press after a long press that got no late contextmenu (iOS); goes red when both pointerdown releases are removed. - A Zed host of "." or ".." is refused; goes red when parsesToItself always returns the URL. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../src/electron/ElectronShell.test.ts | 21 ++ apps/desktop/src/electron/ElectronShell.ts | 4 +- .../ChatMarkdown.permissions.browser.test.tsx | 291 +++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 77 ++++- .../chat/MarkdownFileLink.logic.test.ts | 142 +++++++++ .../components/chat/MarkdownFileLink.logic.ts | 102 ++++-- .../src/components/chat/MarkdownFileLink.tsx | 186 +++++++++-- apps/web/src/components/media/mediaContent.ts | 11 +- apps/web/src/index.css | 5 + apps/web/src/markdown-links.test.ts | 1 + apps/web/src/markdownFileHostPath.ts | 1 + apps/web/src/remoteOpen.test.ts | 78 ++++- apps/web/src/remoteOpen.ts | 15 +- packages/contracts/src/editor.test.ts | 91 ++++++ packages/contracts/src/editor.ts | 48 ++- 15 files changed, 995 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/components/chat/MarkdownFileLink.logic.test.ts create mode 100644 packages/contracts/src/editor.test.ts diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9864c19b0d0ab..39c1cc8556df5 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -169,6 +169,27 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open remote editor URLs whose dot segments move the host", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://ssh/dev@devbox/../evil.example/tmp/x.ts"), + electronShell.openExternal("zed://ssh/dev@devbox/%2E%2E/evil.example/tmp/x.ts"), + electronShell.openExternal( + "vscode://vscode-remote/ssh-remote+devbox/../ssh-remote+evil.example/etc/x.ts:1:1", + ), + electronShell.openExternal( + "vscode://vscode-remote/ssh-remote+devbox/%2e%2E/ssh-remote+evil.example/etc/x.ts:1:1", + ), + ]); + + assert.deepEqual(results, [false, false, false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open unsafe external URLs", () => Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index e342361750f41..16c0e96e992aa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -51,7 +51,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { const url = new URL(rawUrl); return SAFE_WEB_PROTOCOLS.has(url.protocol) || APP_LAUNCH_URLS.has(url.href) || - isRemoteEditorUrl(url) || + // The host of a remote editor URL sits in its path, where a `..` the parser removed has + // already replaced it, so only a URL that parses to itself is opened. + (isRemoteEditorUrl(url) && url.href === rawUrl) || isLocalEditorFileUrl(url) ? Option.some(url.href) : Option.none(); diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index 61cf3c140278a..2958b72510444 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -10,7 +10,8 @@ import { import { AsyncResult } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -import { act, render } from "../../test/render"; +import { act, fakeTimers, render } from "../../test/render"; +import type { RemoteOpenState } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; const state = vi.hoisted(() => ({ @@ -24,6 +25,10 @@ const state = vi.hoisted(() => ({ openFile: vi.fn(), openExternal: vi.fn(), copy: vi.fn(), + openRemote: vi.fn(), + setMaximized: vi.fn(), + remote: { mode: "local-exec" } as RemoteOpenState, + connection: null as { httpBaseUrl: string } | null, pullRequests: [] as ThreadPullRequestLink[], })); @@ -48,8 +53,11 @@ vi.mock(import("../state/session"), async (original) => { const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => id !== null && (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed); + const actual = await original(); return { - ...(await original()), + ...actual, + readPreparedConnection: ((id: EnvironmentId) => + state.connection ?? actual.readPreparedConnection(id)) as never, readEnvironmentScope, useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => useSyncExternalStore( @@ -80,7 +88,9 @@ vi.mock(import("../state/entities"), async (original) => ({ })); vi.mock(import("../remoteOpen"), async (original) => ({ ...(await original()), - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, + useRemoteOpenResolution: () => ({ state: state.remote, isResolved: true }) as never, + useRemoteCapableEditors: () => ["vscode"], + openRemoteEditorUrl: state.openRemote, })); vi.mock(import("../hooks/useLocalStorage"), async (original) => ({ ...(await original()), @@ -137,10 +147,14 @@ function setup() { state.choose.mockReset().mockResolvedValue(null); state.openFile.mockReset(); state.openExternal.mockReset().mockResolvedValue(undefined); + state.openRemote.mockReset().mockResolvedValue(true); + state.setMaximized.mockReset(); + state.remote = { mode: "local-exec" }; + state.connection = null; state.copy.mockReset().mockResolvedValue(undefined); - const { openFile } = useRightPanelStore.getState(); - useRightPanelStore.setState({ openFile: state.openFile }); - stack.defer(() => useRightPanelStore.setState({ openFile })); + const { openFile, setMaximized } = useRightPanelStore.getState(); + useRightPanelStore.setState({ openFile: state.openFile, setMaximized: state.setMaximized }); + stack.defer(() => useRightPanelStore.setState({ openFile, setMaximized })); stack.use(vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy)); stack.use( vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => { @@ -185,11 +199,13 @@ it("removes host actions after revocation while keeping file preview and copying using _setup = setup(); await using screen = await renderMarkdown("[Readme](docs/readme.md)"); await screen.openContextMenu(); - expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); + const fileActions = ["open-in-panel", "open-full-size"]; + const copies = ["download", "copy-relative", "copy-full"]; + expect(offeredActions()).toEqual([...fileActions, "open", "reveal", ...copies]); await setAllowed(false); state.choose.mockResolvedValue("copy-full"); await screen.openContextMenu(); - expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); + expect(offeredActions()).toEqual([...fileActions, ...copies]); await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md")); await act(async () => screen.anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); @@ -278,3 +294,262 @@ it.each(["link-to-thread", "unlink-from-thread"])( ); }, ); + +it("opens a remote environment's file in this machine's editor at the linked line", async () => { + using _setup = setup(); + state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "devbox" } }; + state.choose.mockResolvedValue("open"); + await using screen = await renderMarkdown("[Setup]()"); + await screen.openContextMenu(); + expect(state.choose.mock.lastCall![0]).toContainEqual({ id: "open", label: "Open in VS Code" }); + await vi.waitFor(() => + expect(state.openRemote).toHaveBeenCalledExactlyOnceWith( + "vscode://vscode-remote/ssh-remote+devbox/work/my%20scripts/setup.ps1:12:1", + ), + ); + expect(state.openEditor).not.toHaveBeenCalled(); + + await act(async () => screen.anchor().click()); + expect(state.openFile).toHaveBeenCalledWith(threadRef, "my scripts/setup.ps1", 12); +}); + +it("opens a file full size and downloads it from the environment", async () => { + using _setup = setup(); + using disposables = new DisposableStack(); + const stack = (disposable: Disposable) => disposables.use(disposable); + state.choose.mockResolvedValueOnce("open-full-size").mockResolvedValueOnce("download"); + await using screen = await renderMarkdown("[Readme](/work/docs/readme.md:3)"); + await screen.openContextMenu(); + expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", 3); + expect(state.setMaximized).toHaveBeenCalledWith(threadRef, true); + + state.connection = { httpBaseUrl: "http://environment.test/" }; + state.search.mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/readme?t=1" })); + const fetched: string[] = []; + stack( + vi.spyOn(window, "fetch").mockImplementation(async (input) => { + fetched.push(String(input)); + return new Response("# Readme"); + }), + ); + const saved: { name: string; text: Promise }[] = []; + const click = HTMLAnchorElement.prototype.click; + stack( + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + if (!this.download) return click.call(this); + saved.push({ name: this.download, text: fetch(this.href).then((r) => r.text()) }); + }), + ); + await screen.openContextMenu(); + await vi.waitFor(() => expect(saved).toHaveLength(1)); + expect(state.search).toHaveBeenCalledWith({ + environmentId: threadRef.environmentId, + input: { + resource: { + _tag: "media-file", + threadId: threadRef.threadId, + path: "/work/docs/readme.md", + disposition: "attachment", + }, + }, + }); + expect(fetched[0]).toBe("http://environment.test/api/assets/readme?t=1"); + expect(saved[0]!.name).toBe("readme.md"); + expect(await saved[0]!.text).toBe("# Readme"); +}); + +it("opens the file menu on a touch long press instead of following the link", async () => { + using _setup = setup(); + await using screen = await renderMarkdown("[Readme](/work/docs/readme.md)"); + using _timers = fakeTimers(); + const press = (type: string) => + screen + .anchor() + .dispatchEvent( + new PointerEvent(type, { bubbles: true, pointerType: "touch", clientX: 5, clientY: 5 }), + ); + await act(async () => { + press("pointerdown"); + vi.advanceTimersByTime(500); + press("pointerup"); + }); + expect(state.choose).toHaveBeenCalledOnce(); + await screen.openContextMenu(); + // The click that ends a touch press has detail 1; element.click() is keyboard-like (detail 0). + await act(async () => + screen + .anchor() + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })), + ); + expect(state.choose).toHaveBeenCalledOnce(); + expect(state.openFile).not.toHaveBeenCalled(); +}); + +function touch(target: Element, type: string, point = 5) { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerType: "touch", + clientX: point, + clientY: point, + }), + ); +} + +it.each([ + { platform: "Android (contextmenu while held)", liftFirst: false }, + { platform: "Windows touch (contextmenu on lift)", liftFirst: true }, +])( + "keeps the real long-press menu open when $platform sends its own contextmenu late", + async ({ liftFirst }) => { + using _setup = setup(); + const { dismissContextMenu, showContextMenuFallback } = await import("../contextMenuFallback"); + let shown: Promise = Promise.resolve(undefined); + state.choose.mockImplementation((items, position) => { + shown = showContextMenuFallback(items, position); + return shown; + }); + await using screen = await renderMarkdown("[Readme](/work/docs/readme.md)"); + // The full set, so the menu's own animation frames (which arm its outside-click dismissal) + // advance with the clock. + using _timers = fakeTimers(); + const menu = () => document.querySelector('[data-context-menu="true"]'); + try { + await act(async () => { + touch(screen.anchor(), "pointerdown"); + vi.advanceTimersByTime(500); + }); + expect(menu()).not.toBeNull(); + await act(async () => { + // The platform's touch & hold delay runs past the menu's arming frames. + vi.advanceTimersByTime(500); + if (liftFirst) touch(screen.anchor(), "pointerup"); + screen.anchor().dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 5, + clientY: 5, + }), + ); + if (!liftFirst) touch(screen.anchor(), "pointerup"); + vi.advanceTimersByTime(100); + }); + expect(menu()).not.toBeNull(); + expect(state.choose).toHaveBeenCalledOnce(); + // The next real gesture still dismisses the menu. + await act(async () => { + document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + vi.advanceTimersByTime(100); + }); + expect(menu()).toBeNull(); + expect(await shown).toBeNull(); + } finally { + dismissContextMenu(); + } + }, +); + +it.each([ + { press: "ends with a click", endingClick: true }, + // Phones usually send no click after a long press that opened a menu. + { press: "ends with no click", endingClick: false }, +])( + "takes keyboard Enter and the menu key on a chip after a long press that $press", + async ({ endingClick }) => { + using _setup = setup(); + await using screen = await renderMarkdown("[Readme](/work/docs/readme.md)"); + { + using _timers = fakeTimers(); + await act(async () => { + touch(screen.anchor(), "pointerdown"); + vi.advanceTimersByTime(500); + touch(screen.anchor(), "pointerup"); + // The click that ends the touch press is not an open. + if (endingClick) { + screen + .anchor() + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); + } + }); + } + expect(state.choose).toHaveBeenCalledOnce(); + expect(state.openFile).not.toHaveBeenCalled(); + + await act(async () => { + screen.anchor().focus(); + screen.anchor().dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + screen + .anchor() + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 0 })); + }); + expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); + + await act(async () => { + screen + .anchor() + .dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ContextMenu" })); + screen + .anchor() + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true })); + }); + expect(state.choose).toHaveBeenCalledTimes(2); + }, +); + +it("takes a right-click and a new long press after a long press that got no late contextmenu", async () => { + // iOS sends no contextmenu for a long press, so nothing ends the wait for one but the next + // gesture. + using _setup = setup(); + await using screen = await renderMarkdown("[Readme](/work/docs/readme.md)"); + using _timers = fakeTimers(); + const longPress = async () => + act(async () => { + touch(screen.anchor(), "pointerdown"); + vi.advanceTimersByTime(500); + touch(screen.anchor(), "pointerup"); + }); + const rightClick = async () => + act(async () => { + screen.anchor().dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + pointerType: "mouse", + button: 2, + clientX: 5, + clientY: 5, + }), + ); + screen.anchor().dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 5, + clientY: 5, + }), + ); + }); + await longPress(); + expect(state.choose).toHaveBeenCalledTimes(1); + await rightClick(); + expect(state.choose).toHaveBeenCalledTimes(2); + await longPress(); + expect(state.choose).toHaveBeenCalledTimes(3); + await rightClick(); + expect(state.choose).toHaveBeenCalledTimes(4); + expect(state.openFile).not.toHaveBeenCalled(); +}); + +it("does not open a menu for a chip that unmounts during a long press", async () => { + using _setup = setup(); + const screen = await renderMarkdown("[Readme](/work/docs/readme.md)"); + using _timers = fakeTimers(); + await act(async () => touch(screen.anchor(), "pointerdown")); + await screen.unmount(); + vi.advanceTimersByTime(1_000); + expect(state.choose).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5674a3df2483e..72274e3667580 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -56,6 +56,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, @@ -113,7 +114,7 @@ import { import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { markdownImageGallery } from "./chat/markdownImageGallery"; import { type MediaActionSource } from "./media/MediaActions"; -import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; +import { downloadFile, resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, @@ -155,7 +156,12 @@ import { readLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { hostEnvironmentIdAtom } from "../state/primaryEnvironment"; import { cn } from "../lib/utils"; -import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; +import { + openRemoteEditorUrl, + resolveRemoteOpenState, + useRemoteCapableEditors, + useRemoteOpenResolution, +} from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -210,7 +216,10 @@ import { } from "./chat/ChatMarkdownMedia"; import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; import { MarkdownFileLink } from "./chat/MarkdownFileLink"; -import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { + canUseMarkdownFileShellActions, + markdownFileRemoteEditorUrl, +} from "./chat/MarkdownFileLink.logic"; import { rehypeChipPunctuation } from "./chat/markdownChipPunctuation"; import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; import { rehypeTextDirection } from "./chat/markdownTextDirection"; @@ -569,6 +578,7 @@ function useChatMarkdownState({ const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); const isClientHost = useAtomValue(clientHostEnvironmentAtom(environmentId)); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = @@ -645,7 +655,15 @@ function useChatMarkdownState({ ); const projects = useProjects(); const [preferredEditor] = usePreferredEditor(availableEditors); - const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); + const remoteCapableEditors = useRemoteCapableEditors(); + const [preferredRemoteEditor] = usePreferredEditor(remoteCapableEditors); + const remoteEditorHost = + remoteOpen.state.mode === "remote-links" && preferredRemoteEditor !== null + ? remoteOpen.state.host + : null; + const preferredEditorMenuLabel = openInEditorMenuLabel( + remoteEditorHost === null ? preferredEditor : preferredRemoteEditor, + ); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, @@ -800,6 +818,38 @@ function useChatMarkdownState({ ? pickWorkspaceLinkMatch(workspaceRelativePath, cwd, result.value.entries) : null; }; + const openFileInRemoteEditor = async (fileLinkMeta: MarkdownFileLinkMeta, path: string) => { + const url = + remoteEditorHost === null || preferredRemoteEditor === null + ? undefined + : markdownFileRemoteEditorUrl({ + editor: preferredRemoteEditor, + host: remoteEditorHost, + path, + cwd, + line: fileLinkMeta.line, + column: fileLinkMeta.column, + }); + if (url !== undefined && (await openRemoteEditorUrl(url))) { + return AsyncResult.success(undefined); + } + return AsyncResult.failure( + Cause.fail(new Error("The editor could not open this file over SSH.")), + ); + }; + const downloadMarkdownFile = async (path: string, fileName: string) => { + if (!threadRef) return; + const { environmentId: fileEnvironmentId, threadId } = threadRef; + const result = await createAssetUrl({ + environmentId: fileEnvironmentId, + input: { resource: { _tag: "media-file", threadId, path, disposition: "attachment" } }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const connection = readPreparedConnection(fileEnvironmentId); + const url = connection && resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("Reconnect to the environment and try again."); + await downloadFile(url, fileName); + }; const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; // Every open supersedes pending lookups, including synchronous opens. @@ -885,12 +935,29 @@ function useChatMarkdownState({ openInPreferredEditor( markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), ) - : undefined + : remoteEditorHost !== null + ? async () => + openFileInRemoteEditor(fileLinkMeta, (await resolveHostPath()).absolutePath) + : undefined } resolveCopyPaths={async () => markdownFileCopyPaths(fileLinkMeta, await resolveHostPath(), cwd) } onOpenInPanel={(path) => openFileInPanel(fileLinkMeta, path)} + onOpenFullSize={ + threadRef && panelPath + ? () => { + openFileInPanel(fileLinkMeta, panelPath); + useRightPanelStore.getState().setMaximized(threadRef, true); + } + : undefined + } + onDownload={ + threadRef && canReadFiles && !isAppBundle + ? async () => + downloadMarkdownFile((await resolveHostPath()).absolutePath, fileLinkMeta.basename) + : undefined + } onOpenMedia={ threadRef && canPreviewMedia ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) diff --git a/apps/web/src/components/chat/MarkdownFileLink.logic.test.ts b/apps/web/src/components/chat/MarkdownFileLink.logic.test.ts new file mode 100644 index 0000000000000..e1ca24a3bbc43 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownFileLink.logic.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownFileLinkMeta } from "../../markdown-links"; +import { markdownFileMenuItems, markdownFileRemoteEditorUrl } from "./MarkdownFileLink.logic"; + +const NOTHING = { + hasLine: false, + workspaceRelativePath: null, + canOpenMedia: false, + canOpenInPanel: false, + openInEditorLabel: null, + canOpenInBrowser: false, + revealLabel: null, + canDownload: false, +}; + +describe("markdownFileMenuItems", () => { + it("offers every way to open a workspace file at a line, then the copies", () => { + expect( + markdownFileMenuItems({ + ...NOTHING, + hasLine: true, + workspaceRelativePath: "src/app.ts", + canOpenInPanel: true, + openInEditorLabel: "Open in VS Code", + revealLabel: "Reveal in Files", + canDownload: true, + }), + ).toEqual([ + { id: "open-in-panel", label: "Open in files viewer" }, + { id: "open-full-size", label: "Open full size" }, + { id: "open", label: "Open in VS Code" }, + { id: "reveal", label: "Reveal in Files" }, + { id: "download", label: "Download to this device" }, + { id: "copy-relative", label: "Copy relative path", separatorBefore: true }, + { id: "copy-full", label: "Copy full path" }, + { id: "copy-path-only", label: "Copy full path without line" }, + ]); + }); + + it("keeps only the full path copy when nothing can open the file", () => { + expect(markdownFileMenuItems(NOTHING)).toEqual([ + { id: "copy-full", label: "Copy full path", separatorBefore: false }, + ]); + }); +}); + +describe("markdownFileRemoteEditorUrl", () => { + const host = { kind: "ssh-alias", host: "devbox" } as const; + const urlFor = (href: string, cwd: string | undefined) => { + const meta = resolveMarkdownFileLinkMeta(href, cwd); + if (!meta) throw new Error(`Expected a file link for ${href}`); + return markdownFileRemoteEditorUrl({ + editor: "vscode", + host, + path: meta.filePath, + cwd, + line: meta.line, + column: meta.column, + }); + }; + + it.each([ + { + name: "a Linux path with spaces and a #L anchor", + href: "/srv/my project/notes file.md#L12", + cwd: "/srv/my project", + expected: "vscode://vscode-remote/ssh-remote+devbox/srv/my%20project/notes%20file.md:12:1", + }, + { + name: "a workspace-relative path with line and column", + href: "src/app.ts:30:5", + cwd: "/home/dev/project", + expected: "vscode://vscode-remote/ssh-remote+devbox/home/dev/project/src/app.ts:30:5", + }, + { + name: "a Windows server path", + href: String.raw`C:\work\My Repo\setup.ps1:7`, + cwd: String.raw`C:\work\My Repo`, + expected: "vscode://vscode-remote/ssh-remote+devbox/C%3A/work/My%20Repo/setup.ps1:7:1", + }, + { + name: "a file without a line", + href: "/srv/app/README.md", + cwd: "/srv/app", + expected: "vscode://vscode-remote/ssh-remote+devbox/srv/app/README.md:1:1", + }, + ])("opens $name on the environment's host", ({ href, cwd, expected }) => { + expect(urlFor(href, cwd)).toBe(expected); + }); + + it("carries the login user from the connection settings", () => { + expect( + markdownFileRemoteEditorUrl({ + editor: "vscode", + host: { kind: "tailscale", host: "devbox.example.ts.net", username: "dev" }, + path: "/srv/app/a.ts", + cwd: undefined, + line: 3, + column: undefined, + }), + ).toBe("vscode://vscode-remote/ssh-remote+dev%40devbox.example.ts.net/srv/app/a.ts:3:1"); + }); + + it.each([ + { editor: "vscode", href: "/../ssh-remote+evil.example/etc/x.ts" }, + { editor: "vscode", href: "/%2E%2E/ssh-remote+evil.example/etc/x.ts" }, + { editor: "zed", href: "/../evil.example/tmp/x.ts" }, + { editor: "zed", href: "/%2E%2E/evil.example/tmp/x.ts" }, + { editor: "zed", href: "/.%2e/evil.example/tmp/x.ts" }, + ] as const)("keeps $editor on the environment's host for $href", ({ editor, href }) => { + const meta = resolveMarkdownFileLinkMeta(href, "/srv/app"); + const url = meta + ? markdownFileRemoteEditorUrl({ + editor, + host, + path: meta.filePath, + cwd: "/srv/app", + line: meta.line, + column: meta.column, + }) + : undefined; + if (url === undefined) return; + const parsed = new URL(url); + expect(parsed.href).toBe(url); + const authority = decodeURIComponent(parsed.pathname.split("/")[1]!); + expect(authority).toBe(editor === "zed" ? "devbox" : "ssh-remote+devbox"); + }); + + it("refuses a relative path it cannot anchor to a workspace", () => { + expect( + markdownFileRemoteEditorUrl({ + editor: "vscode", + host, + path: "src/app.ts", + cwd: undefined, + line: undefined, + column: undefined, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/MarkdownFileLink.logic.ts b/apps/web/src/components/chat/MarkdownFileLink.logic.ts index d42ff1f9e8448..c0578d30856fa 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.logic.ts +++ b/apps/web/src/components/chat/MarkdownFileLink.logic.ts @@ -1,11 +1,14 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { splitFilePathPosition } from "@t3tools/shared/fileLinks"; - import { - shouldOpenMarkdownFileLinkInBrowserByDefault, - type MarkdownFileLinkMeta, -} from "../../markdown-links"; -import type { RemoteOpenMode } from "../../remoteOpen"; + buildRemoteOpenUrl, + type ContextMenuItem, + type EditorId, + type EnvironmentId, +} from "@t3tools/contracts"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { isAbsolutePath } from "@t3tools/shared/path"; + +import { shouldOpenMarkdownFileLinkInBrowserByDefault } from "../../markdown-links"; +import type { RemoteOpenHost, RemoteOpenMode } from "../../remoteOpen"; export function canUseMarkdownFileShellActions( environmentId: EnvironmentId | null, @@ -29,23 +32,76 @@ export function hasMarkdownFilePrimaryAction(input: { ); } -export function markdownFileCopyMenuItems({ - targetPath, - workspaceRelativePath, -}: Pick) { - const items = [ - { id: "copy-full", label: "Copy full path", value: targetPath, title: "Full path" }, +export type MarkdownFileMenuAction = + | "preview-media" + | "open-in-panel" + | "open-full-size" + | "open" + | "open-in-browser" + | "reveal" + | "download" + | "copy-relative" + | "copy-full" + | "copy-path-only"; + +export function markdownFileMenuItems(input: { + hasLine: boolean; + workspaceRelativePath: string | null; + canOpenMedia: boolean; + canOpenInPanel: boolean; + openInEditorLabel: string | null; + canOpenInBrowser: boolean; + revealLabel: string | null; + canDownload: boolean; +}): ContextMenuItem[] { + const actions: ReadonlyArray = [ + ["preview-media", input.canOpenMedia && "Preview media"], + ["open-in-panel", input.canOpenInPanel && "Open in files viewer"], + ["open-full-size", input.canOpenInPanel && "Open full size"], + ["open", input.openInEditorLabel], + ["open-in-browser", input.canOpenInBrowser && "Open in integrated browser"], + ["reveal", input.revealLabel], + ["download", input.canDownload && "Download to this device"], ]; - if (workspaceRelativePath !== null) { - const position = targetPath.slice(splitFilePathPosition(targetPath).path.length); - items.unshift({ - id: "copy-relative", - label: "Copy relative path", - value: `${workspaceRelativePath}${position}`, - title: "Relative path", - }); - } - return items; + const copies: ReadonlyArray = [ + ["copy-relative", input.workspaceRelativePath !== null && "Copy relative path"], + ["copy-full", "Copy full path"], + ["copy-path-only", input.hasLine && "Copy full path without line"], + ]; + const present = (entries: typeof actions) => + entries.flatMap(([id, label]) => (label ? [{ id, label }] : [])); + const opens = present(actions); + const [firstCopy, ...otherCopies] = present(copies); + return [ + ...opens, + ...(firstCopy ? [{ ...firstCopy, separatorBefore: opens.length > 0 }] : []), + ...otherCopies, + ]; +} + +// The editor runs on the viewing machine and reaches the environment's files over SSH. +export function markdownFileRemoteEditorUrl(input: { + editor: EditorId; + host: RemoteOpenHost; + path: string; + cwd: string | undefined; + line: number | undefined; + column: number | undefined; +}): string | undefined { + const absolutePath = + isAbsolutePath(input.path) || input.cwd === undefined + ? input.path + : resolvePathLinkTarget(input.path, input.cwd); + if (!isAbsolutePath(absolutePath)) return undefined; + return buildRemoteOpenUrl({ + editor: input.editor, + host: input.host.host, + ...(input.host.username === undefined ? {} : { username: input.host.username }), + absolutePath, + isFile: true, + ...(input.line === undefined ? {} : { line: input.line }), + ...(input.column === undefined ? {} : { column: input.column }), + }); } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { diff --git a/apps/web/src/components/chat/MarkdownFileLink.tsx b/apps/web/src/components/chat/MarkdownFileLink.tsx index 0fa8eaf4f30f6..50ff75ba21bed 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.tsx +++ b/apps/web/src/components/chat/MarkdownFileLink.tsx @@ -6,7 +6,14 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { memo, useState, type MouseEvent as ReactMouseEvent } from "react"; +import { + memo, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import { readLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; @@ -19,7 +26,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { FileTagChipContent } from "./FileTagChip"; import { hasMarkdownFilePrimaryAction, - markdownFileCopyMenuItems, + markdownFileMenuItems, shouldUseMarkdownFileBrowserPrimaryAction, } from "./MarkdownFileLink.logic"; import { reportMarkdownActionFailure } from "./markdownActionFailure"; @@ -40,6 +47,8 @@ interface MarkdownFileLinkProps { threadRef?: ScopedThreadRef | undefined; onOpen?: (() => Promise>) | undefined; onOpenInPanel: (panelPath: string, line: number | undefined) => void; + onOpenFullSize?: (() => void) | undefined; + onDownload?: (() => Promise) | undefined; resolveCopyPaths: () => Promise>; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; @@ -49,6 +58,8 @@ interface MarkdownFileLinkProps { } const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; +const LONG_PRESS_MS = 500; +const LONG_PRESS_SLOP_PX = 10; export const MarkdownFileLink = memo(function MarkdownFileLink({ environmentId, @@ -65,6 +76,8 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + onOpenFullSize, + onDownload, openInEditorMenuLabel, onOpenInBrowser, onOpenMedia, @@ -198,6 +211,20 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }; + const handleDownload = () => { + if (!onDownload) return; + onDownload().catch((cause: unknown) => { + reportMarkdownActionFailure({ operation: "download-file", target: targetPath }, cause); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to download file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + }); + }; + const handleCopy = (resolveValue: () => Promise, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -249,44 +276,46 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ if (!api) return; try { - const copyItems = markdownFileCopyMenuItems({ targetPath, workspaceRelativePath }); const clicked = await api.contextMenu.show( - [ - ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - ...copyItems.map(({ id, label }) => ({ id, label })), - ] as const, + markdownFileMenuItems({ + hasLine: line !== undefined, + workspaceRelativePath, + canOpenMedia: onOpenMedia !== undefined, + canOpenInPanel, + openInEditorLabel: onOpen ? openInEditorMenuLabel : null, + canOpenInBrowser: onOpenInBrowser !== undefined, + revealLabel: onReveal && revealLabel ? revealLabel : null, + canDownload: onDownload !== undefined, + }), position, ); if (clicked === "preview-media") { onOpenMedia?.(); - return; - } - if (clicked === "open") { + } else if (clicked === "open-in-panel" && panelPath) { + onOpenInPanel(panelPath, line); + } else if (clicked === "open-full-size") { + onOpenFullSize?.(); + } else if (clicked === "open") { handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { + } else if (clicked === "open-in-browser") { handleOpenInBrowser(); - return; - } - if (clicked === "reveal") { + } else if (clicked === "reveal") { handleRevealInFileManager(); - return; - } - if (clicked === "copy-relative" || clicked === "copy-full") { - handleCopy( - () => - resolveCopyPaths().then((paths) => - clicked === "copy-relative" ? paths.relativePath : paths.fullPath, - ), - clicked === "copy-relative" ? "Relative path" : "Full path", - ); + } else if (clicked === "download") { + handleDownload(); + } else if ( + clicked === "copy-relative" || + clicked === "copy-full" || + clicked === "copy-path-only" + ) { + const copy = { + "copy-relative": ["relativePath", "Relative path"], + "copy-full": ["fullPath", "Full path"], + "copy-path-only": ["pathOnly", "Full path"], + } as const; + const [key, title] = copy[clicked]; + handleCopy(() => resolveCopyPaths().then((paths) => paths[key]), title); } } catch (cause) { reportMarkdownActionFailure( @@ -296,9 +325,70 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ } }; + // iOS Safari never fires contextmenu for a touch, so a long press opens the same menu. + const longPress = useRef<{ + timer: ReturnType | undefined; + x: number; + y: number; + fired: boolean; + releaseContextMenu: (() => void) | undefined; + }>({ timer: undefined, x: 0, y: 0, fired: false, releaseContextMenu: undefined }); + const cancelLongPress = () => { + clearTimeout(longPress.current.timer); + longPress.current.timer = undefined; + }; + const releaseLateContextMenu = () => { + longPress.current.releaseContextMenu?.(); + longPress.current.releaseContextMenu = undefined; + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the long-press timer and the window contextmenu listener this chip holds until unmount. + useEffect(() => { + const pending = longPress.current; + return () => { + clearTimeout(pending.timer); + pending.releaseContextMenu?.(); + }; + }, []); + const handlePointerDown = (event: ReactPointerEvent) => { + cancelLongPress(); + releaseLateContextMenu(); + longPress.current.fired = false; + if (event.pointerType !== "touch") return; + const chip = event.currentTarget; + const { clientX: x, clientY: y } = event; + longPress.current.x = x; + longPress.current.y = y; + longPress.current.timer = setTimeout(() => { + longPress.current.timer = undefined; + longPress.current.fired = true; + // Android (after its touch & hold delay) and Windows touch (on lift) send their own + // contextmenu for this press after the timer. The open menu would take it as an outside + // right-click and close, so the first one on the chip is swallowed before anything sees it. + longPress.current.releaseContextMenu = swallowLateContextMenu(chip); + void showFileContextMenu({ x, y }); + }, LONG_PRESS_MS); + }; + // Only the click that ends the touch press; keyboard activation has detail 0. + const consumeLongPressClick = (event: ReactMouseEvent) => { + if (!longPress.current.fired || event.detail === 0) return false; + longPress.current.fired = false; + return true; + }; + const handlePointerMove = (event: ReactPointerEvent) => { + const { x, y } = longPress.current; + if (Math.hypot(event.clientX - x, event.clientY - y) > LONG_PRESS_SLOP_PX) cancelLongPress(); + }; + const longPressHandlers = { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: cancelLongPress, + onPointerCancel: cancelLongPress, + }; + const handleContextMenu = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); + cancelLongPress(); const position = event.clientX === 0 && event.clientY === 0 ? (() => { @@ -337,9 +427,11 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ render={} className={MARKDOWN_FILE_LINK_CLASS_NAME} data-markdown-copy={copyMarkdown} + {...longPressHandlers} onClick={(event) => { event.preventDefault(); event.stopPropagation(); + if (consumeLongPressClick(event)) return; if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { handleOpenInEditor(); return; @@ -373,7 +465,15 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ aria-haspopup="menu" className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} + {...longPressHandlers} + onClick={(event) => { + if (!consumeLongPressClick(event)) { + handleContextMenu(event); + return; + } + event.preventDefault(); + event.stopPropagation(); + }} onContextMenu={handleContextMenu} > void { + const onContextMenu = (event: MouseEvent) => { + if (!(event.target instanceof Node) || !chip.contains(event.target)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + release(); + }; + const release = () => { + window.removeEventListener("contextmenu", onContextMenu, true); + window.removeEventListener("pointerdown", release, true); + window.removeEventListener("keydown", release, true); + }; + window.addEventListener("contextmenu", onContextMenu, true); + window.addEventListener("pointerdown", release, true); + window.addEventListener("keydown", release, true); + return release; +} + function areMarkdownFileLinkPropsEqual( previous: Readonly, next: Readonly, @@ -417,6 +537,8 @@ function areMarkdownFileLinkPropsEqual( previous.isAppBundle === next.isAppBundle && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && + previous.onOpenFullSize === next.onOpenFullSize && + previous.onDownload === next.onDownload && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && diff --git a/apps/web/src/components/media/mediaContent.ts b/apps/web/src/components/media/mediaContent.ts index a5a4cec61143a..77c6ea436ceb8 100644 --- a/apps/web/src/components/media/mediaContent.ts +++ b/apps/web/src/components/media/mediaContent.ts @@ -69,7 +69,16 @@ async function readMediaBlob(src: string): Promise { } export async function downloadMedia(src: string, name: string): Promise { - const url = URL.createObjectURL(await readMediaBlob(src)); + saveBlob(await readMediaBlob(src), name); +} + +/** Any file type, an HTML page included. */ +export async function downloadFile(src: string, name: string): Promise { + saveBlob(await fetchMediaBlob(src), name); +} + +function saveBlob(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); try { const anchor = document.createElement("a"); anchor.href = url; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 654ef108e8e75..46619625bfebf 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2039,6 +2039,11 @@ code { text-decoration: none; } +/* A long press opens the file link's own menu instead of the iOS link callout. */ +.chat-markdown-file-link { + -webkit-touch-callout: none; +} + /* File links and context chips own their color and focus ring. */ .chat-markdown a:not(.chat-markdown-file-link, :has([data-slot="context-chip-label"])) { color: var(--info-foreground); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 4b60a2cad3366..d8ba6b8a1f77a 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -132,6 +132,7 @@ describe("chat file chip paths", () => { ); expect(markdownFileCopyPaths(meta, hostPath, cwd)).toEqual({ fullPath: String.raw`C:\Users\alice\project\src\discord\events\user.ts:261:7`, + pathOnly: String.raw`C:\Users\alice\project\src\discord\events\user.ts`, relativePath: "src/discord/events/user.ts:261:7", }); }); diff --git a/apps/web/src/markdownFileHostPath.ts b/apps/web/src/markdownFileHostPath.ts index 26f3aaa348122..aa62e4ba466bf 100644 --- a/apps/web/src/markdownFileHostPath.ts +++ b/apps/web/src/markdownFileHostPath.ts @@ -40,6 +40,7 @@ export function markdownFileCopyPaths( const fullPath = markdownFileEditorTarget(meta, hostPath); return { fullPath, + pathOnly: hostPath.matched ? hostPath.absolutePath : meta.filePath, relativePath: formatFilePathPosition({ ...meta, path: workspaceRelativeFilePath(hostPath.absolutePath, cwd) ?? meta.filePath, diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 12149f98af238..fee1166ff51f5 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -4,9 +4,9 @@ import { SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { remoteFallbackEditors, resolveRemoteOpenState } from "./remoteOpen"; +import { openRemoteEditorUrl, remoteFallbackEditors, resolveRemoteOpenState } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -285,4 +285,78 @@ describe("buildRemoteOpenUrl", () => { ).toBe(expected); } }); + + it.each([ + { + editor: "vscode", + line: 42, + expected: "vscode://vscode-remote/ssh-remote+sol/tmp/my%20file.md:42:1", + }, + { + editor: "cursor", + line: 42, + column: 7, + expected: "cursor://vscode-remote/ssh-remote+sol/tmp/my%20file.md:42:7", + }, + ] as const)("opens $editor at a file's line", ({ editor, expected, ...position }) => { + expect( + buildRemoteOpenUrl({ + editor, + host: "sol", + absolutePath: "/tmp/my file.md", + isFile: true, + ...position, + }), + ).toBe(expected); + }); + + // Their ssh deep links have no position field, so the line is not sent and the menu entry only + // says "Open in ". + it.each([ + { editor: "zed", expected: "zed://ssh/sol/tmp/my%20file.md" }, + { + editor: "idea", + expected: + "jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=IU&projectHint=%2Ftmp%2Fmy+file.md", + }, + ] as const)("opens $editor on the file without its line", ({ editor, expected }) => { + expect( + buildRemoteOpenUrl({ + editor, + host: "sol", + absolutePath: "/tmp/my file.md", + isFile: true, + line: 42, + column: 7, + }), + ).toBe(expected); + }); +}); + +describe("openRemoteEditorUrl", () => { + it.each([ + "zed://ssh/dev@devbox/../evil.example/tmp/x.ts", + "vscode://vscode-remote/ssh-remote+devbox/%2E%2E/ssh-remote%2Bevil.example/etc/x.ts:1:1", + ])("refuses %s, which a URL parser reads with another host", async (url) => { + const assign = vi.fn(); + vi.stubGlobal("window", { location: { assign } }); + try { + expect(await openRemoteEditorUrl(url)).toBe(false); + expect(assign).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("opens a URL that parses to itself", async () => { + const assign = vi.fn(); + vi.stubGlobal("window", { location: { assign } }); + try { + const url = "zed://ssh/dev@devbox/tmp/x.ts"; + expect(await openRemoteEditorUrl(url)).toBe(true); + expect(assign).toHaveBeenCalledExactlyOnceWith(url); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index a62b203663f07..b744660639295 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -173,8 +173,13 @@ export function useRemoteCapableEditors(): ReadonlyArray { return useAtomValue(remoteCapableEditorsAtom); } -/** Resolves false when the desktop refuses the URL, including unsupported editor schemes. */ +/** + * Resolves false when the desktop refuses the URL, including unsupported editor schemes, and for + * a URL a parser reads differently from how it was written: the host of a remote editor URL sits + * in its path, so a dot segment the parser removes replaces the host. + */ export async function openRemoteEditorUrl(url: string): Promise { + if (!parsesToItself(url)) return false; const bridge = window.desktopBridge; if (bridge !== undefined) { try { @@ -187,6 +192,14 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } +function parsesToItself(url: string): boolean { + try { + return new URL(url).href === url; + } catch { + return false; + } +} + const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { diff --git a/packages/contracts/src/editor.test.ts b/packages/contracts/src/editor.test.ts new file mode 100644 index 0000000000000..b236d6f50f0fe --- /dev/null +++ b/packages/contracts/src/editor.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { buildRemoteOpenUrl, type EditorId } from "./editor.ts"; + +// The host of a remote editor URL lives in its path (`zed://ssh//...`, +// `vscode://vscode-remote/ssh-remote+/...`), so a dot segment in the file path must never +// reach the URL: every URL parser removes it together with the segment before it, the host. +describe("buildRemoteOpenUrl keeps the host when the path climbs", () => { + const climbs = [ + "/../evil.example/tmp/x.ts", + "/../ssh-remote+evil.example/etc/x.ts", + "/../../evil.example/x.ts", + "/./../evil.example/x.ts", + "/srv/../../evil.example/x.ts", + String.raw`C:\..\evil.example\x.ts`, + ]; + const editors: ReadonlyArray = ["vscode", "cursor", "zed"]; + + it.each(editors.flatMap((editor) => climbs.map((absolutePath) => ({ editor, absolutePath }))))( + "$editor: $absolutePath stays on dev@devbox", + ({ editor, absolutePath }) => { + const url = buildRemoteOpenUrl({ + editor, + host: "devbox", + username: "dev", + absolutePath, + isFile: true, + line: 3, + }); + expect(url).toBeDefined(); + // The built URL is exactly what a URL parser (Electron, the OS handler) reads. + expect(new URL(url!).href).toBe(url); + const segments = new URL(url!).pathname.split("/"); + expect(decodeURIComponent(segments[1]!)).toMatch( + editor === "zed" ? /^dev@devbox$/ : /^ssh-remote\+dev@devbox$/, + ); + expect(url).not.toMatch(/\/\.\.?(?:\/|$)/); + }, + ); + + it.each([ + { editor: "vscode", expected: "vscode://vscode-remote/ssh-remote+sol/evil.example/x.ts:1:1" }, + { editor: "zed", expected: "zed://ssh/sol/evil.example/x.ts" }, + ] as const)("$editor resolves a climb above the root to the root", ({ editor, expected }) => { + expect( + buildRemoteOpenUrl({ + editor, + host: "sol", + absolutePath: "/../evil.example/x.ts", + isFile: true, + }), + ).toBe(expected); + }); + + it("keeps a climb that stays below the root on the same host", () => { + expect( + buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "/home/u/../v/a.ts" }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/home/v/a.ts"); + }); + + it("keeps a Windows drive as the root a climb cannot leave", () => { + expect( + buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: String.raw`C:\..\x.ts` }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/x.ts"); + expect( + buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: String.raw`C:\a\..\..\x.ts` }), + ).toBe("zed://ssh/sol/x.ts"); + }); + + it("encodes a literal %2E%2E name so it is never read as a climb", () => { + const url = buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/%2E%2E/x.ts" }); + expect(url).toBe("zed://ssh/sol/%252E%252E/x.ts"); + expect(new URL(url!).href).toBe(url); + }); + + it.each([".", ".."])("refuses a Zed host of %s, which a URL parser would drop", (host) => { + // `zed://ssh/../a.ts` parses to `zed://ssh/a.ts`: the host segment is gone, and only the + // parse-to-itself check catches it. + expect(buildRemoteOpenUrl({ editor: "zed", host, absolutePath: "/a.ts" })).toBeUndefined(); + }); + + it("keeps a host with a slash inside its own encoded segment", () => { + for (const host of ["evil/../x", "a/b", ".."]) { + for (const editor of ["vscode", "zed"] as const) { + const url = buildRemoteOpenUrl({ editor, host, username: "dev", absolutePath: "/a.ts" }); + expect(url).toBeDefined(); + expect(new URL(url!).href).toBe(url); + } + } + }); +}); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 8732d7fe288e2..1cbc1b08b9da8 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -187,12 +187,47 @@ export const isLocalEditorFileUrl = (url: URL): boolean => url.password.length === 0 && url.pathname.length > 1; +// Resolves `.` and `..` the way a URL parser would, but never above the root (or a Windows drive): +// in remote editor URLs the host is the path segment before the file path, so a `..` left in the +// path removes the host and the next segment becomes the host. +const normalizeRootedPath = (rootedPath: string): string => { + const segments: string[] = []; + const parts = rootedPath.slice(1).split("/"); + const driveRoot = /^[A-Za-z]:$/.test(parts[0] ?? "") ? 1 : 0; + for (const part of parts) { + if (part === ".") continue; + if (part === "..") { + if (segments.length > driveRoot) segments.pop(); + continue; + } + segments.push(part); + } + return `/${segments.join("/")}`; +}; + +const rootPath = (absolutePath: string): string => { + const posixPath = absolutePath.replaceAll("\\", "/"); + return posixPath.startsWith("/") ? posixPath : `/${posixPath}`; +}; + +// The built URL is what Electron and the OS handler parse; refuse any that a parser reads +// differently (another host, path or authority) instead of trusting that encoding covered it. +const parsesToItself = (url: string): string | undefined => { + try { + return new URL(url).href === url ? url : undefined; + } catch { + return undefined; + } +}; + export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; readonly username?: string; readonly absolutePath: string; readonly isFile?: boolean; + readonly line?: number; + readonly column?: number; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); if (scheme === undefined) { @@ -204,16 +239,16 @@ export const buildRemoteOpenUrl = (input: { h: input.host, launchIde: "true", ideHint: editor.jetbrainsProductCode, + // A query value, so dot segments cannot reach the host here. projectHint: input.absolutePath.replaceAll("\\", "/"), }); if (input.username !== undefined) { params.set("u", input.username); } - return `${scheme}://gateway/ssh/environment?${params.toString()}`; + return parsesToItself(`${scheme}://gateway/ssh/environment?${params.toString()}`); } // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. - const posixPath = input.absolutePath.replaceAll("\\", "/"); - const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; + const rootedPath = normalizeRootedPath(rootPath(input.absolutePath)); const encodedHost = encodeURIComponent(input.host); if (input.editor === "zed") { // Zed roots paths on the system drive, so omit the Windows drive prefix. @@ -224,7 +259,7 @@ export const buildRemoteOpenUrl = (input: { input.username === undefined ? encodedHost : `${encodeURIComponent(input.username)}@${encodedHost}`; - return `${scheme}://ssh/${zedDestination}${encodedZedPath}`; + return parsesToItself(`${scheme}://ssh/${zedDestination}${encodedZedPath}`); } const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); const plainDestination = @@ -238,7 +273,10 @@ export const buildRemoteOpenUrl = (input: { ).join("") : plainDestination; // VS Code opens a bare vscode-remote path as a folder; a trailing position makes it open the file. - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(destination)}${encodedPath}${input.isFile ? ":1:1" : ""}`; + const position = input.isFile ? `:${input.line ?? 1}:${input.column ?? 1}` : ""; + return parsesToItself( + `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(destination)}${encodedPath}${position}`, + ); }; export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); From 4264469636ce05363d02eabe43b4a199883bb13c Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:41:15 +0100 Subject: [PATCH 12/27] fork-ci: docs-scope composite action (docs-only gate and per-job path scope) (#16) * fork-ci: docs-scope composite action (docs-only gate and per-job path scope) A composite action that says whether a CI run's change is docs-only and which configured jobs it reaches. The rules (docs globs, docs a build reads, per-job paths, paths listed in a JSON registry) are a config read from the base commit; the code runs from the action's folder at the caller's pinned SHA, so a pull request cannot loosen what judges it. Anything unclear means a full run, and every skip prints why. fork-ci-tests.cjs now also finds tests under fork-ci/. Co-Authored-By: Claude Opus 5.5 * fork-ci docs-scope: verify the merge commit, never crash, fail closed on unclear lists - pull_request: HEAD must be $GITHUB_SHA and HEAD^2 the event's pull_request.head.sha before HEAD^1 is trusted as the base (a PR-head checkout that merged main in made code docs-only and read the config from the PR). Other events check $GITHUB_SHA too. - git output decoded with surrogateescape, JSON read as bytes, RecursionError caught, and main() turns any error into a full run with outputs (no crash, no missing outputs). - `**` matches across a newline; a name with a control character or non-UTF-8 bytes is never docs; printed lines escape control characters (no workflow-command injection). - *_from lists: a kept object without the field, a field no kept object has, or an entry that is not a plain path is a full run; source and never_docs value types checked. - A non-docs file no job names, or a config change, runs every job; a config path that is not plain is a full run. - README: the push base is the last green commit, never github.event.before (why). - fork-ci-tests: presentTests() and a real test of the fork-ci folder scan. Co-Authored-By: Claude Opus 5.5 * fork-ci docs-scope: base rules decide the full run, push runs only as base, no hidden gitlinks - "A file no job names runs every job" is judged by the base's *_from entries only; the PR's own entries can add a job but never cancel the full run (a new module listing a root file did). - A base off HEAD's first-parent line is a full run, and the README's last-green query takes push runs only (event=push): a fork PR from a branch called main no longer counts as a tested main commit. - git diff --ignore-submodules=none: .gitmodules ignore=all or diff.ignoreSubmodules cannot hide a gitlink bump. - The reason says "scoped run" when jobs are scoped, "full run" only when every job runs. - Tests for unknown keys in never_docs_from and paths_from entries. Co-Authored-By: Claude Opus 5.5 * fork-ci docs-scope: test that never_docs_from is read at the PR head too; wrap three README lines A PR that adds a registry entry for docs/manual.md and edits that file runs every job. Reading never_docs_from at the base only turns this test red (docs-only, every job skipped). Co-Authored-By: Claude Opus 5.5 * fork-ci docs-scope: every test starts with no GITHUB_* variable (the runner's event file leaked in) test_no_github_sha_or_event_is_a_full_run [event file] failed on fork CI run 43452154314: scope() built the script's env from os.environ and set GITHUB_EVENT_PATH only when the test gave a head sha, so the job's own event file stood in for the unset one. A CleanEnv base class now strips every GITHUB_* key from os.environ for each test (restored after); Repo, Globs, Readme and Action derive from it. Red with GITHUB_EVENT_PATH set to a real event file before, green after. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 3 +- .github/scripts/fork-ci-tests.cjs | 16 +- .github/scripts/fork-ci-tests.test.cjs | 18 +- .github/workflows/fork-tests.yml | 4 +- fork-ci/actions/docs-scope/README.md | 169 +++++ fork-ci/actions/docs-scope/action.yml | 42 + fork-ci/actions/docs-scope/docs-scope.test.py | 715 ++++++++++++++++++ fork-ci/actions/docs-scope/docs_scope.py | 340 +++++++++ 8 files changed, 1300 insertions(+), 7 deletions(-) create mode 100644 fork-ci/actions/docs-scope/README.md create mode 100644 fork-ci/actions/docs-scope/action.yml create mode 100644 fork-ci/actions/docs-scope/docs-scope.test.py create mode 100644 fork-ci/actions/docs-scope/docs_scope.py diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 76cefd07c8701..703a769df63d0 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -1,4 +1,4 @@ -# Every test file under .github/scripts (*.test.*, subdirectories included) and +# Every test file under .github/scripts and fork-ci (*.test.*, subdirectories included) and # where it runs (.github/scripts/fork-ci-tests.cjs checks this list). # run by the workflows job, by extension (sh, py, cjs, mjs, js) # run by run by , which must name the test's path (not one @@ -15,6 +15,7 @@ .github/scripts/fork-test-shards.test.cjs .github/scripts/fork-tests-drift.test.cjs .github/scripts/fork-workflow-triggers.test.cjs +fork-ci/actions/docs-scope/docs-scope.test.py # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/.github/scripts/fork-ci-tests.cjs b/.github/scripts/fork-ci-tests.cjs index 2bca87ed6b6eb..f3de52e0bd593 100644 --- a/.github/scripts/fork-ci-tests.cjs +++ b/.github/scripts/fork-ci-tests.cjs @@ -1,4 +1,4 @@ -// Fork only: every `*.test.*` under .github/scripts must be listed in .github/fork-ci-tests.txt. +// Fork only: every `*.test.*` under .github/scripts and fork-ci must be listed in .github/fork-ci-tests.txt. // A plain entry runs here by its extension (RUNNER); ` run by ` is run by that file, // which must name the test and cannot be a (disabled) upstream workflow. Unlisted or runner-less // tests fail; a listed test that is gone fails once the tested history had it, a note before. @@ -8,7 +8,7 @@ const path = require("node:path"); const ROOT = path.resolve(__dirname, "../.."); const LIST = ".github/fork-ci-tests.txt"; -const SCRIPTS = ".github/scripts"; +const TEST_DIRS = [".github/scripts", "fork-ci"]; const TEST_FILE = /\.test\.([^./]+)$/; const RUNNER = { sh: ["bash"], @@ -83,6 +83,14 @@ function findTests(dir, root = ROOT) { return out.toSorted(); } +// Every test file under TEST_DIRS. A folder that is not there adds none: a listed test in it is then +// missing, which plan() reports (a failure once the tested history had it). +function presentTests(root = ROOT) { + return TEST_DIRS.filter((dir) => fs.existsSync(path.join(root, dir))).flatMap((dir) => + findTests(dir, root), + ); +} + function git(args) { return childProcess.execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); } @@ -113,7 +121,7 @@ function readFile(file) { function main() { const entries = readList(fs.readFileSync(path.join(ROOT, LIST), "utf8")); - const result = plan(entries, findTests(SCRIPTS), everInHistory, readFile); + const result = plan(entries, presentTests(), everInHistory, readFile); const rows = []; let failed = result.unlisted.length + result.deleted.length + result.noRunner.length + result.notRun.length; @@ -167,6 +175,6 @@ function main() { if (failed) process.exitCode = 1; } -module.exports = { plan, readList, findTests }; +module.exports = { plan, readList, findTests, presentTests }; if (require.main === module) main(); diff --git a/.github/scripts/fork-ci-tests.test.cjs b/.github/scripts/fork-ci-tests.test.cjs index 28c72efa155b6..3ce9bf2e938b0 100644 --- a/.github/scripts/fork-ci-tests.test.cjs +++ b/.github/scripts/fork-ci-tests.test.cjs @@ -4,7 +4,7 @@ const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); -const { plan, readList, findTests } = require("./fork-ci-tests.cjs"); +const { plan, readList, findTests, presentTests } = require("./fork-ci-tests.cjs"); const never = () => false; const here = (...files) => files.map((file) => ({ file, runBy: null })); @@ -96,3 +96,19 @@ test("finds every *.test.* file, subdirectories included", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + +test("the composite actions' tests under fork-ci are found too, and a missing folder adds none", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fork-ci-tests-")); + const files = [".github/scripts/a.test.cjs", "fork-ci/actions/x/b.test.py", "other/c.test.py"]; + try { + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), ""); + } + assert.deepEqual(presentTests(root), files.slice(0, 2)); + fs.rmSync(path.join(root, "fork-ci"), { recursive: true }); + assert.deepEqual(presentTests(root), files.slice(0, 1)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml index 72e5e25e1a1a7..cb7b1ad104668 100644 --- a/.github/workflows/fork-tests.yml +++ b/.github/workflows/fork-tests.yml @@ -176,12 +176,14 @@ jobs: timeout-minutes: 5 steps: # scripts/: some tests are run by a script there (.github/fork-ci-tests.txt). + # fork-ci/: the composite actions and their tests. - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ needs.changes.outputs.sha }} sparse-checkout: | /.github/ + /fork-ci/ /scripts/ sparse-checkout-cone-mode: false @@ -199,7 +201,7 @@ jobs: tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" actionlint "$RUNNER_TEMP/actionlint" -color .github/workflows/fork-*.yml - # Every test under .github/scripts, checked against .github/fork-ci-tests.txt: + # Every test under .github/scripts and fork-ci, checked against .github/fork-ci-tests.txt: # the fork's tests run here, the others must be named by the job or script # that runs them. An unlisted test, or a listed one deleted from history, # fails; a listed one whose pull request has not merged yet is a note. diff --git a/fork-ci/actions/docs-scope/README.md b/fork-ci/actions/docs-scope/README.md new file mode 100644 index 0000000000000..b42889b6ae15c --- /dev/null +++ b/fork-ci/actions/docs-scope/README.md @@ -0,0 +1,169 @@ +# docs-scope + +A composite action that answers two questions about the change a CI run tests: + +- **Is it docs-only?** Then the build and test jobs can pass at once. +- **Which jobs does it reach?** A job runs when a changed file that is not docs matches its paths. A file that is + not docs but that no job names (a root build file, a workflow), or a change to the config, runs every job. + +It is the general half of a docs-only gate. The project half (which files are docs, which docs a build or test +reads, which folders each job builds) is a JSON config in the calling repository. + +## Usage + +Pin the action by full SHA (the tagged one, so a rebase of this fork cannot orphan it): + +```yaml +jobs: + build: + steps: + - uses: actions/checkout@ # the default ref: on pull_request GitHub's merge commit ($GITHUB_SHA) + with: { fetch-depth: 0 } # the merge base must be there + # Optional, push only: the last commit whose push run of this workflow passed (see "The base on push"). + - id: green + if: github.event_name == 'push' + env: { GH_TOKEN: "${{ github.token }}" } # needs `actions: read` + run: | + sha=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?branch=main&event=push&status=success&per_page=1" \ + --jq '.workflow_runs[0].head_sha // ""') || sha="" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - id: scope + uses: scuttletoby/t3code/fork-ci/actions/docs-scope@ # ci/vN + with: + config: .github/docs-scope.json # default + base: ${{ steps.green.outputs.sha }} # empty: a full run + - if: steps.scope.outputs.docs_only != 'true' + run: ./gradlew build + - if: fromJSON(steps.scope.outputs.jobs).engine != 'false' + run: ./gradlew :engine:test +``` + +Always test `!= 'true'` and `!= 'false'`, never `== 'false'` or `== 'true'`: a missing or empty output then means +"run". `jobs` is `{}` when the change or the config is unknown, so every job reads as "run". + +Run it as a step of the jobs it gates, as above. If it runs in a job of its own that others `need`, a failure of that +job skips them, and GitHub counts a skipped required job as passing: gate them with +`if: ${{ !cancelled() && (needs.scope.result != 'success' || fromJSON(needs.scope.outputs.jobs).engine != 'false') }}`. + +### Outputs + +| Output | Value | +| ----------- | ------------------------------------------------------------------------------ | +| `docs_only` | `true` only when every changed file is docs and the event may be docs-only | +| `jobs` | JSON `{"": "true" \| "false"}`; `{}` when unknown | +| `reason` | one line: `docs-only:`, `full run:`, `scoped run:` or `not docs-only:` and why | + +The step log also prints one line per job: `job engine: run (engine/A.java matches its paths)` or +`job engine: skip (...)`. Control characters, line separators and bytes that are not UTF-8 in a file name are +printed escaped (`\n`, `\udcff`), so a file name can neither start a `::workflow-command::` line nor add an output. + +## What "the change" is + +- **pull_request:** the union of two diffs without rename detection (a rename is its old and its new path): + merge base .. PR head (every push of the PR together, never the last push alone) and base .. GitHub's merge commit. + HEAD must be that merge commit: HEAD == `$GITHUB_SHA`, it has two parents, and HEAD^2 is the event's + `pull_request.head.sha`. Only then is HEAD^1 trusted as the base. A checkout of the PR head is a full run: if its + last commit merged main in, HEAD^1 would be the PR's own commit, the PR's code would drop out of both diffs and the + config would be read from the PR. +- **Any other event** (push, merge_group, workflow_dispatch): `base` .. HEAD, only when the caller gives `base` and + HEAD == `$GITHUB_SHA`, and the base must be on HEAD's first-parent line. Without a base the run is a full run. Such + an event is docs-only only when `docs_only_events` names it; its `jobs` are read either way, so a docs-only push to + main can still skip the build jobs. + +### The base on push + +Pass the last commit whose **push** run of the workflow on main **passed**, or nothing (a full run). Never +`github.event.before`: if push A (code) went red or was cancelled (for example by `concurrency`), push B (docs) diffs +B against A, every job reads `false`, and main turns green with A's code never tested; a rerun of B gives the same +answer. Against the last green commit, A's code is in the diff until a run that tests it passes. + +Keep `event=push` in the query: `branch` matches a run's head branch, so without it a passing `pull_request` run from +a fork whose branch is also called `main` counts, and its head commit is a PR head that no run tested as main (the PR +run tested GitHub's merge commit). The action enforces the same rule: the base must be on HEAD's first-parent line +(main's own pushes), so a merged branch's commit, another branch or a force-pushed-away commit is a full run, and so +is a base that is not in the checkout. A rerun can see a newer green base than the first run did; that only shrinks +the diff to commits a passing push run already tested. + +## Config + +```json +{ + "docs": ["**/*.md"], + "never_docs": [ + { "glob": "**/src/**", "why": "inside a source set (packed into jars)" }, + { "glob": "**/tests/**", "why": "inside a test folder" }, + { "glob": "tools/CLAIMS.md", "why": "read by the release tool" } + ], + "never_docs_from": [ + { + "file": "tools/modules.json", + "list": "modules", + "field": "inputs", + "label": "id", + "why": "a module input" + } + ], + "docs_only_events": ["pull_request"], + "jobs": { + "engine": { + "paths": ["engine/**", "shared/items/**"], + "paths_from": [ + { + "file": "tools/modules.json", + "list": "modules", + "where": { "kind": "engine-module" }, + "field": ["paths", "inputs"], + "label": "id" + } + ] + } + } +} +``` + +- `docs` (required): globs of docs files. `**/` is any number of folders, `**` anything (a newline in a name too), + `*` and `?` stay in one name. A name with a control character or bytes that are not UTF-8 is never docs. +- `never_docs`: files that match `docs` but that a build, test or tool reads; each says why. +- `never_docs_from` / `paths_from`: paths listed in a JSON file of the repository. `list` is a dotted key to a list + of objects (none: the file itself), `where` keeps the objects whose keys equal these values, `field` names the key + (or keys) holding a path or a list of paths, `label` names an object in the log. An entry matches a path exactly or + as a folder. The file is read at the base and at the head; an entry at either makes a file not docs or runs a job, + so a pull request cannot free a file by removing it from the list. Whether a file is named by no job (so every job + runs) is judged by the base's entries only: a pull request that adds an entry for a root file, even with a new + module, adds that job but cannot stop the full run. A list that could drop a rule quietly is an error: a kept + object with none of the `field` keys, a `field` key no kept object has (a typo, or a `where` that keeps nothing), + or an entry that is not a plain repository path (`./x`, `/x`, `x/../y`, `x//y`, a wildcard), which could never + match. +- `docs_only_events`: events that may be docs-only (default `["pull_request"]`). +- `jobs`: per job, `paths` globs and `paths_from` lists. Docs files never run a job; a `never_docs` file does. A + file that is not docs and that no job names runs every job, and so does a change to the config. + +Unknown keys and wrong types are an error (a typo such as `never_doc` must not drop a rule quietly), and an error +means a full run that names it. The `config` input must be a plain repository path (no `./`, `..` or leading `/`), +so the "config is never docs" rule cannot be dodged by spelling. + +## Why a pull request cannot loosen it + +- The script runs from the action's own folder, at the SHA the caller pins: never a copy from the checkout. +- The config is read from the base commit with `git show`, never from the working tree. A pull request that changes + the config is a full run (the config file is never docs). + +What it cannot stop: a `pull_request` run uses the workflow file from the pull request, so a pull request that edits +the `uses:` line chooses the code that judges it. That edit is a change to a non-docs file, so every correct version +runs in full for it; pin only SHAs that this fork's CI tested and tagged. + +## Fails closed + +A full run (and `jobs` = `{}`) for: an event without a base, a base off HEAD's first-parent line, HEAD that is not +`$GITHUB_SHA`, a pull_request HEAD that is not GitHub's merge commit (two parents, HEAD^2 = the event's PR head; no +event file), no merge base (shallow checkout), a base that is not a commit, a git error, an empty change, a config +path that is not plain, no config at the base, a config that does not parse or has unknown keys or wrong types, a +`*_from` file at neither side or not readable as configured (not UTF-8, nested too deep, a missing field, an entry +that is not a plain path). Any other error is caught too and gives a full run with its message, never a step without +outputs. A symlink or submodule is never docs, and the diff uses `--ignore-submodules=none`, so neither +`ignore = all` in `.gitmodules` nor the runner's `diff.ignoreSubmodules` can hide a gitlink bump. + +## Tests + +`docs-scope.test.py` builds real git repositories with GitHub-style merge commits. It runs in the fork's CI +(`.github/fork-ci-tests.txt`): `python3 fork-ci/actions/docs-scope/docs-scope.test.py`. diff --git a/fork-ci/actions/docs-scope/action.yml b/fork-ci/actions/docs-scope/action.yml new file mode 100644 index 0000000000000..6a0e7dc90f0a1 --- /dev/null +++ b/fork-ci/actions/docs-scope/action.yml @@ -0,0 +1,42 @@ +name: docs-scope +description: >- + Is this change docs-only, and which configured jobs does it reach? Reads its rules from the base + commit and runs its own code at the pinned SHA, so a pull request cannot loosen what judges it. + Anything unclear means a full run. Usage: README.md next to this file. +inputs: + config: + description: >- + The JSON config, a plain path in the repository (no ./); read at the base commit, never from the checkout. + required: false + default: .github/docs-scope.json + base: + description: >- + Events other than pull_request only: the commit to compare HEAD with, the last commit whose + push run passed (event=push; never github.event.before: see README), on HEAD's first-parent + line. Empty or off that line: such runs run in full. + required: false + default: "" +outputs: + docs_only: + description: "'true' only when every changed file is docs and the event may be docs-only; test != 'true'." + value: ${{ steps.scope.outputs.docs_only }} + jobs: + description: >- + JSON {"": "true"|"false"}; {} when the change or the config is unknown. Read a job as + fromJSON(steps..outputs.jobs). != 'false', so a missing job runs. + value: ${{ steps.scope.outputs.jobs }} + reason: + description: One line saying why. + value: ${{ steps.scope.outputs.reason }} +runs: + using: composite + steps: + # Only code from this action's folder (the caller's pinned SHA) runs; nothing from the checkout. + # The script also reads the runner's GITHUB_SHA and GITHUB_EVENT_PATH (HEAD must be the run's commit). + - id: scope + shell: bash + env: + CONFIG: ${{ inputs.config }} + BASE: ${{ inputs.base }} + EVENT: ${{ github.event_name }} + run: python3 -I "$GITHUB_ACTION_PATH/docs_scope.py" --config "$CONFIG" --event "$EVENT" --base "$BASE" diff --git a/fork-ci/actions/docs-scope/docs-scope.test.py b/fork-ci/actions/docs-scope/docs-scope.test.py new file mode 100644 index 0000000000000..f8164a2a27e08 --- /dev/null +++ b/fork-ci/actions/docs-scope/docs-scope.test.py @@ -0,0 +1,715 @@ +"""docs_scope.py: a docs-only change builds and tests nothing; jobs run only for the files that reach them. +Real git repos: GitHub's refs/pull/N/merge is a merge commit (HEAD^1 = the base, HEAD^2 = the PR head), built here +with `git merge --no-ff`. Anything unclear must mean a full run. +Run: python3 fork-ci/actions/docs-scope/docs-scope.test.py +""" +import contextlib, json, os, re, shutil, subprocess, sys, tempfile, unittest +from unittest import mock + +sys.dont_write_bytecode = True # the Globs test imports docs_scope.py: leave no __pycache__ in the action folder + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPT = os.path.join(HERE, 'docs_scope.py') +ACTION = os.path.join(HERE, 'action.yml') +README = os.path.join(HERE, 'README.md') +CONFIG_PATH = '.github/docs-scope.json' +ENV = {'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@t', 'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@t', + 'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1'} +REGISTRY_PATH = 'tools/registry.json' +REGISTRY = {'modules': [ + {'id': 'site', 'kind': 'plugin', 'inputs': ['plugins/site/README.md']}, + {'id': 'content-locale', 'kind': 'engine-module', 'inputs': ['plugins/locale/common/src/main/java/Wire.java']}, + {'id': 'content-travel', 'kind': 'engine-module', 'inputs': ['plugins/travel/common/src/main/java']}, +]} +# A project's rules, as a caller would write them (the general half of a docs-only gate). +CONFIG = { + 'docs': ['**/*.md'], + 'never_docs': [ + {'glob': 'src/**', 'why': 'inside a source set'}, + {'glob': '**/src/**', 'why': 'inside a source set'}, + {'glob': 'resources/**', 'why': 'inside a resources folder'}, + {'glob': '**/resources/**', 'why': 'inside a resources folder'}, + {'glob': '**/tests/**', 'why': 'inside a test folder'}, + {'glob': '**/test/**', 'why': 'inside a test folder'}, + {'glob': '**/fixtures/**', 'why': 'inside a fixture folder'}, + {'glob': 'skills/review/SKILL.md', 'why': 'read by a tooling test'}, + {'glob': 'plugins/RELEASE-CLAIMS.md', 'why': 'read by the release tool'}, + ], + 'never_docs_from': [{'file': REGISTRY_PATH, 'list': 'modules', 'field': 'inputs', 'label': 'id', + 'why': 'listed in the registry'}], + 'jobs': { + 'plugins': {'paths': ['plugins/**']}, + 'engine': {'paths': ['engine/**', 'plugins/items/**'], + 'paths_from': [{'file': REGISTRY_PATH, 'list': 'modules', 'where': {'kind': 'engine-module'}, + 'field': 'inputs', 'label': 'id'}]}, + }, +} + + +class CleanEnv(unittest.TestCase): + """Every test starts with no GITHUB_* variable. On a runner the job's own GITHUB_EVENT_PATH, GITHUB_SHA and + GITHUB_OUTPUT would otherwise reach the script (fork CI run 43452154314: the job's event file stood in for the + one a test leaves unset). os.environ is restored after each test.""" + + def setUp(self): + patcher = mock.patch.dict(os.environ) + patcher.start() + self.addCleanup(patcher.stop) + for key in [k for k in os.environ if k.startswith('GITHUB_')]: + del os.environ[key] + + +class Repo(CleanEnv): + config = CONFIG + + def setUp(self): + super().setUp() + self.d = tempfile.mkdtemp(prefix='docs-scope-') + self.addCleanup(shutil.rmtree, self.d, True) + self.git('init', '-q', '-b', 'main') + self.write({'README.md': 'x', 'app/Main.java': 'class Main {}', REGISTRY_PATH: json.dumps(REGISTRY), + CONFIG_PATH: json.dumps(self.config)}) + self.base = self.commit('base') + + def git(self, *a): + r = subprocess.run(['git', *a], cwd=self.d, text=True, capture_output=True, env={**os.environ, **ENV}) + if r.returncode: + raise RuntimeError(f'git {a}: {r.stderr}') + return r.stdout.strip() + + def write(self, files): + for path, text in files.items(): # a bytes path or text is written as those bytes + full = os.path.join(os.fsencode(self.d), os.fsencode(path)) + if text is None: + os.remove(full) + continue + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, 'wb') as f: + f.write(text if isinstance(text, bytes) else text.encode()) + + def commit(self, msg='c', files=None): + if files: + self.write(files) + self.git('add', '-A') + self.git('commit', '-q', '--allow-empty', '-m', msg) + return self.git('rev-parse', 'HEAD') + + def pr(self, *pushes, main_after=None): + """The PR branch gets one commit per push ({path: text, None = delete}); main may move on; then GitHub's + merge commit (main + PR head) is checked out.""" + self.git('checkout', '-q', '-b', 'pr', self.base) + for files in pushes: + self.commit('push', files) + self.git('checkout', '-q', 'main') + if main_after: + self.commit('main moved', main_after) + self.git('merge', '-q', '--no-ff', '-m', 'merge', 'pr') + + def scope(self, event='pull_request', base='', config=CONFIG_PATH, sha=None, head_sha=None): + """(docs_only, jobs, stdout). The runner's view: $GITHUB_SHA is HEAD and the event's pull_request.head.sha + is HEAD^2 (HEAD for a commit that is not a merge), unless the test says otherwise ('' = unset).""" + out, event_path = os.path.join(self.d, '.output'), os.path.join(self.d, '.event.json') + head = self.git('rev-parse', 'HEAD') + parents = self.git('rev-list', '--parents', '-n', '1', 'HEAD').split() + head_sha = (parents[2] if len(parents) == 3 else head) if head_sha is None else head_sha + env = {**os.environ, **ENV, 'GITHUB_OUTPUT': out, 'GITHUB_SHA': head if sha is None else sha} + if head_sha: + with open(event_path, 'w') as f: + json.dump({'pull_request': {'head': {'sha': head_sha}}}, f) + env['GITHUB_EVENT_PATH'] = event_path + r = subprocess.run([sys.executable, '-I', SCRIPT, '--config', config, '--event', event, '--base', base], + cwd=self.d, capture_output=True, env=env) + stdout = r.stdout.decode() # strict: the script prints UTF-8 only, whatever the file names hold + self.assertEqual(r.returncode, 0, r.stderr.decode()) + with open(out, 'rb') as f: + written = f.read().decode() + for f in (out, event_path): + os.remove(f) if os.path.exists(f) else None + m = re.fullmatch(r'docs_only=(true|false)\njobs=(\{.*\})\nreason=([^\n]+)\n', written) + self.assertTrue(m, written) + self.assertEqual(stdout.startswith('docs-only: '), m[1] == 'true', stdout) + self.assertEqual(m[3], stdout.splitlines()[0]) + for line in stdout.splitlines(): # a file name never starts a line: no ::workflow-command:: + self.assertRegex(line, r'^(docs-only|full run|scoped run|not docs-only|job [^:]+):', stdout) + self.assertTrue(line.isprintable(), repr(line)) + return m[1] == 'true', json.loads(m[2]), stdout + + def reset(self): + self.git('checkout', '-q', '-f', 'main') + self.git('reset', '-q', '--hard', self.base) + for b in ('pr', 'pr-real'): + self.git('branch', '-q', '-D', b) if self.git('branch', '--list', b) else None + + def assertDocsOnly(self, expected): + docs, _, out = self.scope() + self.assertEqual(docs, expected, out) + return out + + +class DocsOnly(Repo): + def test_only_md_files_is_docs_only(self): + self.pr({'README.md': 'y', 'docs/guide.md': 'new'}) + self.assertDocsOnly(True) + + def test_md_and_code_is_a_full_run(self): + self.pr({'README.md': 'y', 'app/Main.java': 'class Main { int x; }'}) + self.assertDocsOnly(False) + + def test_main_moving_on_with_code_does_not_count_against_the_pr(self): # base..merge = the PR's change only + self.pr({'README.md': 'y'}, main_after={'app/Main.java': 'class Main { int y; }'}) + self.assertDocsOnly(True) + + +class EveryPushCounts(Repo): + def test_a_code_push_then_a_docs_push_is_a_full_run(self): # never the last push alone + self.pr({'app/Util.java': 'class Util {}'}, {'README.md': 'y'}) + self.assertDocsOnly(False) + + def test_code_added_and_removed_again_leaves_a_docs_only_change(self): # the whole change is what merges + self.pr({'app/Util.java': 'class Util {}'}, {'app/Util.java': None, 'README.md': 'y'}) + self.assertDocsOnly(True) + + def test_a_rename_from_java_to_md_is_a_full_run(self): # --no-renames: the deleted .java is listed + self.pr({'app/Main.java': None, 'app/Main.md': 'class Main {}'}) + self.assertIn('app/Main.java', self.assertDocsOnly(False)) + + def test_a_code_change_main_already_has_still_counts(self): # base..merge misses it; merge base..PR head has it + same = {'app/Main.java': 'class Main { int same; }'} + self.pr({**same, 'README.md': 'y'}, main_after=same) + self.assertDocsOnly(False) + + +class BuildAndTestInputs(Repo): + def check(self, path): + self.pr({path: 'doc'}) + self.assertIn('build or test input', self.assertDocsOnly(False), path) + + def test_md_in_a_source_set(self): + self.check('plugins/x/src/main/resources/help.md') + + def test_md_in_a_top_level_source_set(self): + self.check('src/help.md') + + def test_md_in_a_resources_folder(self): + self.check('net/resources/motd.md') + + def test_md_in_a_test_folder(self): + self.check('net/testing/livelike/tests/fixture.md') + + def test_md_in_a_fixtures_folder(self): + self.check('x/fixtures/case.md') + + def test_a_named_file_read_by_a_test(self): + self.check('skills/review/SKILL.md') + + def test_a_named_file_read_by_a_tool(self): + self.check('plugins/RELEASE-CLAIMS.md') + + def test_an_md_listed_in_the_registry(self): + self.check('plugins/site/README.md') + + def test_an_md_newly_listed_in_the_registry_by_main(self): # read at the base too, not only the head + self.pr({'docs/new.md': 'doc'}, main_after={REGISTRY_PATH: json.dumps( + {'modules': REGISTRY['modules'] + [{'id': 'docs-site', 'inputs': ['docs/']}]})}) + self.assertIn('docs-site inputs: docs', self.assertDocsOnly(False)) + + +class RegistryAtTheBase(Repo): + """A pull request cannot free a file by deleting its entry: the base's entries count. Here the registry change + itself does not decide (it is docs, or a job's own path), so only the registry read can (an entry the PR adds + counts too: never_docs_from is read at the head as well).""" + config = {'docs': ['**'], 'never_docs_from': CONFIG['never_docs_from'], + 'jobs': {'plugins': {'paths': ['plugins/**', 'tools/**']}, 'engine': CONFIG['jobs']['engine']}} + + def drop(self, module_id): + return json.dumps({'modules': [m for m in REGISTRY['modules'] if m['id'] != module_id]}) + + def test_a_pr_that_drops_a_never_docs_entry_does_not_free_the_file(self): + self.pr({REGISTRY_PATH: self.drop('site'), 'plugins/site/README.md': 'y'}) + docs, _, out = self.scope() + self.assertFalse(docs, out) + self.assertIn('site inputs: plugins/site/README.md', out) + + def test_a_pr_that_drops_a_jobs_entry_still_runs_the_job(self): + self.pr({REGISTRY_PATH: self.drop('content-locale'), 'plugins/locale/common/src/main/java/Wire.java': 'x'}) + self.assertEqual(self.scope()[1], {'plugins': 'true', 'engine': 'true'}) + + def test_a_pr_that_adds_a_never_docs_entry_counts_it_at_the_head(self): # never_docs_from read at both sides + self.pr({REGISTRY_PATH: json.dumps({'modules': REGISTRY['modules'] + [{'id': 'manual', + 'inputs': ['docs/manual.md']}]}), + 'docs/manual.md': 'y'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {'plugins': 'true', 'engine': 'true'}), out) + self.assertIn('manual inputs: docs/manual.md', out) + + +class RegistryAtTheHead(Repo): + """The PR's own registry entries may add a job, never cancel "a file no job names runs every job": the rules that + judge a PR come from the base. The registry sits in a job's paths (the common case), so its change alone does not + run every job.""" + config = {**CONFIG, 'jobs': {'plugins': {'paths': ['plugins/**']}, + 'engine': {**CONFIG['jobs']['engine'], 'paths': ['engine/**', 'tools/**']}}} + + def add(self, *modules): + return json.dumps({'modules': REGISTRY['modules'] + list(modules)}) + + def test_a_head_only_entry_cannot_claim_a_file_no_job_names(self): # even innocently: a new module lists it + self.pr({REGISTRY_PATH: self.add({'id': 'root', 'kind': 'engine-module', 'inputs': ['build.gradle']}), + 'build.gradle': 'x'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {'plugins': 'true', 'engine': 'true'}), out) + self.assertIn('full run: build.gradle is not a docs file', out.splitlines()[0]) + self.assertIn('which no job names at the base: every job runs', out.splitlines()[0]) + + def test_a_head_only_entry_still_adds_its_job(self): + self.pr({REGISTRY_PATH: self.add({'id': 'new', 'kind': 'engine-module', 'inputs': ['plugins/new/X.java']}), + 'plugins/new/X.java': 'x'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {'plugins': 'true', 'engine': 'true'}), out) + self.assertIn('job engine: run (plugins/new/X.java matches new inputs', out) + + +class Submodules(Repo): + """A gitlink bump is never docs, whatever .gitmodules (at the base, so in the checkout) or the runner's git config + says about ignoring submodules.""" + + def setUp(self): + super().setUp() + os.makedirs(os.path.join(self.d, 'lib')) # an uninitialised submodule: an empty folder + self.write({'.gitmodules': '[submodule "lib"]\n\tpath = lib\n\turl = ./lib\n\tignore = all\n'}) + self.git('update-index', '--add', '--cacheinfo', f'160000,{"1" * 40},lib') + self.base = self.commit('a submodule that .gitmodules ignores') + + def bump(self): + self.git('checkout', '-q', '-b', 'pr', self.base) + self.git('update-index', '--cacheinfo', f'160000,{"2" * 40},lib') + self.commit('bump lib', {'README.md': 'y'}) + self.git('checkout', '-q', 'main') + self.git('merge', '-q', '--no-ff', '-m', 'merge', 'pr') + + def test_a_submodule_bump_with_ignore_all_in_gitmodules(self): + self.bump() + self.assertIn('lib is a symlink or submodule', self.assertDocsOnly(False)) + + def test_a_submodule_bump_with_diff_ignore_submodules_in_the_git_config(self): + self.write({'.gitmodules': None}) # only the runner's config hides it now (the base still has .gitmodules) + self.git('rm', '-q', '--cached', '.gitmodules') + self.base = self.commit('no .gitmodules') + self.git('config', 'diff.ignoreSubmodules', 'all') + self.bump() + self.assertIn('lib is a symlink or submodule', self.assertDocsOnly(False)) + + +class FailsClosed(Repo): + def test_events_without_a_base_are_full_runs(self): + self.pr({'README.md': 'y'}) + for event in ('push', 'workflow_dispatch', ''): + docs, jobs, out = self.scope(event) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('without a base', out) + + def test_a_head_that_is_not_a_merge_commit(self): + self.commit('docs on main', {'README.md': 'y'}) + self.assertIn('not a two-parent merge commit', self.assertDocsOnly(False)) + + def test_no_merge_base(self): + self.git('checkout', '-q', '--orphan', 'pr') + self.git('rm', '-rqf', '.') + self.commit('unrelated', {'README.md': 'z'}) + self.git('checkout', '-q', 'main') + self.git('merge', '-q', '--no-ff', '--allow-unrelated-histories', '-X', 'theirs', '-m', 'merge', 'pr') + self.assertIn('no merge base', self.assertDocsOnly(False)) + + def test_an_empty_change(self): + self.pr({}) + self.assertIn('touches no file', self.assertDocsOnly(False)) + + def test_an_md_symlink(self): + self.git('checkout', '-q', '-b', 'pr') + os.symlink('app/Main.java', os.path.join(self.d, 'link.md')) + self.commit('symlink') + self.git('checkout', '-q', 'main') + self.git('merge', '-q', '--no-ff', '-m', 'merge', 'pr') + self.assertIn('symlink', self.assertDocsOnly(False)) + + def test_a_registry_at_neither_side(self): + self.git('checkout', '-q', '-b', 'pr2', self.base) + self.git('rm', '-q', REGISTRY_PATH) + main = self.commit('no registry on this side branch') + self.git('checkout', '-q', '-b', 'pr3', main) + self.commit('docs', {'README.md': 'w'}) + self.git('checkout', '-q', 'pr2') + self.git('merge', '-q', '--no-ff', '-m', 'merge', 'pr3') + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('neither the base nor the head', out) + + def test_a_registry_that_does_not_parse_as_configured(self): + self.pr({'README.md': 'y'}, main_after={REGISTRY_PATH: json.dumps({'mods': []})}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('cannot be read as configured', out) + + def test_registry_entries_that_could_never_match_are_a_full_run(self): # never fail open on a list + site = {'id': 'site', 'kind': 'plugin', 'inputs': ['plugins/site/README.md']} + for name, modules in ( + ('a renamed field', [{**site, 'inputs': None, 'input': ['plugins/site/README.md']}]), + ('an object without the field', [site, {'id': 'other', 'kind': 'plugin'}]), + ('./', [{**site, 'inputs': ['./plugins/site/README.md']}]), + ('a leading /', [{**site, 'inputs': ['/plugins/site/README.md']}]), + ('..', [{**site, 'inputs': ['plugins/../plugins/site/README.md']}]), + ('a wildcard', [{**site, 'inputs': ['plugins/**/*.md']}]), + ('a number', [{**site, 'inputs': [5]}]), + ('no object of the kind a job keeps', None)): + with self.subTest(name): + self.reset() + engine = [m for m in REGISTRY['modules'] if m['kind'] == 'engine-module'] if modules else [] + modules = [{k: v for k, v in m.items() if v is not None} for m in modules or [site]] + engine + self.base = self.commit('registry', {REGISTRY_PATH: json.dumps({'modules': modules})}) + self.pr({'plugins/site/README.md': 'y'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('cannot be read as configured', out) + + +class MergeCommit(Repo): + """On pull_request, HEAD must be GitHub's merge commit before HEAD^1 is trusted as the base: a checkout of a PR + head whose last commit merged main in would make the PR's code vanish from both diffs and read the config from + the PR.""" + + def setUp(self): + super().setUp() + self.git('checkout', '-q', '-b', 'pr', self.base) + self.commit('code + a looser config', {'app/Main.java': 'class Main { int x; }', + CONFIG_PATH: json.dumps({'docs': ['**']})}) + self.git('checkout', '-q', 'main') + self.commit('docs on main', {'README.md': 'y'}) + self.git('checkout', '-q', 'pr') + self.git('merge', '-q', '--no-ff', '-m', 'merge main into pr', 'main') + self.pr_head = self.git('rev-parse', 'HEAD') + self.git('checkout', '-q', 'main') + self.git('merge', '-q', '--no-ff', '-m', 'merge', 'pr') + self.github_sha = self.git('rev-parse', 'HEAD') # what GitHub's refs/pull/N/merge would hold + self.git('checkout', '-q', '--detach', self.pr_head) # but the caller checked out the PR head + + def test_the_real_merge_commit_is_a_full_run(self): # the check does not refuse the right checkout + self.git('checkout', '-q', '--detach', self.github_sha) + self.assertEqual(self.scope(head_sha=self.pr_head)[:2], (False, {'plugins': 'true', 'engine': 'true'})) + + def test_a_pr_head_checkout_is_a_full_run(self): + docs, jobs, out = self.scope(sha=self.github_sha, head_sha=self.pr_head) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('is not the commit this run is for', out) + + def test_a_merge_whose_second_parent_is_not_the_pr_head_is_a_full_run(self): + docs, jobs, out = self.scope(sha=self.pr_head, head_sha=self.pr_head) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn("not the event's pull_request.head.sha", out) + + def test_no_github_sha_or_event_is_a_full_run(self): + self.git('checkout', '-q', '--detach', self.github_sha) + for sha, head_sha, why in (('', self.pr_head, 'unset'), (None, '', 'event file')): + with self.subTest(why): + docs, jobs, out = self.scope(sha=sha, head_sha=head_sha) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn(why, out) + + def test_a_push_run_checks_github_sha_too(self): + self.git('checkout', '-q', 'main') + docs, jobs, out = self.scope('push', self.base, sha=self.pr_head) + self.assertEqual((docs, jobs), (False, {}), out) + + +class NeverCrashes(Repo): + """Every error is a full run with outputs. A crash leaves no outputs: a `needs:` job that is skipped reads as + passing on a required check.""" + + def test_a_file_name_that_is_not_utf8(self): + self.pr({b'docs/\xff.md': 'x'}) + docs, jobs, out = self.scope() + self.assertFalse(docs, out) + self.assertIn('not UTF-8', out) + self.assertIn('docs/\\udcff.md', out) + + def test_a_registry_at_the_head_that_is_not_utf8_or_too_deep(self): + for name, text in (('not utf-8', b'\xff'), ('too deep', '[' * 200000)): + with self.subTest(name): + self.reset() + self.pr({REGISTRY_PATH: text, 'README.md': 'y'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('cannot be read as configured', out) + + def test_an_error_nobody_foresaw(self): # the last net: main() turns any exception into a full run + sys.path.insert(0, HERE) + try: + import docs_scope + finally: + sys.path.remove(HERE) + out = os.path.join(self.d, '.output') + + def broken(*a): + raise RuntimeError('boom') + old = os.environ.get('GITHUB_OUTPUT') + os.environ['GITHUB_OUTPUT'] = out + try: + with open(os.devnull, 'w') as devnull, contextlib.redirect_stdout(devnull): + self.assertEqual(docs_scope.main(['--config', CONFIG_PATH, '--event', 'pull_request'], run=broken), 0) + finally: + os.environ.pop('GITHUB_OUTPUT') if old is None else os.environ.__setitem__('GITHUB_OUTPUT', old) + with open(out) as f: + self.assertEqual(f.read(), 'docs_only=false\njobs={}\nreason=full run: docs-scope failed ' + '(RuntimeError: boom)\n') + + +class OddNames(Repo): + def test_a_newline_name_in_a_source_set_is_not_docs(self): + self.pr({'p/src/main/resources/he\nlp.md': 'x'}) + self.assertDocsOnly(False) + + def test_a_newline_name_in_a_job_folder_runs_that_job_by_its_paths(self): + self.pr({'plugins/x\ny.java': 'x'}) + docs, jobs, out = self.scope() + self.assertEqual(jobs, {'plugins': 'true', 'engine': 'false'}, out) + self.assertIn('matches its paths', out) + + def test_a_name_cannot_inject_a_workflow_command_or_an_output(self): # scope() checks every line + self.pr({'a\ndocs_only=true\njobs={}\n::warning::injected\n.java': 'x', 'README.md': 'y'}) + docs, _, out = self.scope() + self.assertFalse(docs, out) + self.assertIn('a\\ndocs_only=true', out) + + +class ConfigAtTheBase(Repo): + """The rules come from the base commit and the code from the action's pinned folder, so a pull request cannot + loosen what judges it (the general half of a gate that ran the PR's own copy).""" + + def test_a_pr_that_loosens_its_own_config_still_runs_in_full(self): + self.pr({CONFIG_PATH: json.dumps({'docs': ['**'], 'jobs': {}}), 'engine/A.java': 'class A {}'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {'plugins': 'true', 'engine': 'true'}), out) # the base's jobs, all + + def test_no_config_at_the_base(self): # the PR that adds the config runs in full + self.git('rm', '-q', CONFIG_PATH) + self.base = self.commit('no config') + self.pr({'README.md': 'y', CONFIG_PATH: json.dumps(CONFIG)}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('no config', out) + + def test_a_bad_config_at_the_base(self): + for bad in ('{', '[]', json.dumps({'docs': ['**/*.md'], 'never_doc': []}), # a typo drops no rule quietly + json.dumps({'never_docs': []}), json.dumps({'docs': '**/*.md'}), + json.dumps({'docs': ['*.md'], 'never_docs': [{'glob': 'x'}]}), + json.dumps({'docs': ['*.md'], 'never_docs_from': [{'file': 'r.json', 'field': 'f'}]}), + json.dumps({'docs': ['*.md'], 'jobs': {'j': {'path': ['x/**']}}}), + json.dumps({'docs': ['*.md'], 'never_docs': [{'glob': 5, 'why': 'x'}]})): # was a crash + with self.subTest(bad=bad): + self.reset() + self.commit('bad config', {CONFIG_PATH: bad}) + self.git('checkout', '-q', '-b', 'pr') + self.commit('docs', {'README.md': bad}) + self.git('checkout', '-q', 'main') + self.git('merge', '-q', '--no-ff', '-m', 'merge', 'pr') + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('config', out) + + + def test_a_bad_source_entry_names_itself(self): # wrong types and typos in a *_from entry: a full run, why + source = CONFIG['never_docs_from'][0] + for bad, why in (({'field': 5}, 'needs a plain file path'), ({'field': []}, 'needs a plain file path'), + ({'where': ['kind']}, 'needs a plain file path'), ({'label': 3}, 'needs a plain file path'), + ({'list': None}, 'needs a plain file path'), ({'file': './' + REGISTRY_PATH}, 'needs a plain'), + ({'field': ['inputs', 'inptus']}, "no kept object has the field(s) ['inptus']"), + ({'where': {'kind': 'plugn'}}, "no kept object has the field(s) ['inputs']"), + ({'wehre': {'kind': 'plugin'}}, 'no other keys than')): # a typo key drops no filter + with self.subTest(bad=bad): + self.reset() + self.commit('bad source', {CONFIG_PATH: json.dumps({**CONFIG, 'never_docs_from': [{**source, **bad}]})}) + self.pr({'README.md': 'y'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn(why, out) + + def test_an_unknown_key_in_a_paths_from_entry(self): # "wehre" would drop the kind filter: the job runs more + engine = CONFIG['jobs']['engine'] + source = {**{k: v for k, v in engine['paths_from'][0].items() if k != 'where'}, 'wehre': {'kind': 'x'}} + self.commit('typo', {CONFIG_PATH: json.dumps( + {**CONFIG, 'jobs': {**CONFIG['jobs'], 'engine': {**engine, 'paths_from': [source]}}})}) + self.pr({'README.md': 'y'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('jobs.engine.paths_from entry', out) + + +class LooseConfig(Repo): + config = {'docs': ['**']} # everything is docs, except the config itself + + def test_the_config_file_is_never_docs(self): + self.pr({CONFIG_PATH: json.dumps({'docs': ['**'], 'jobs': {}})}) + self.assertIn('the docs-scope config', self.assertDocsOnly(False)) + + def test_a_config_path_that_is_not_plain_is_a_full_run(self): # ./x would dodge "the config is never docs" + self.pr({CONFIG_PATH: json.dumps({'docs': ['**'], 'jobs': {}})}) + for config in ('./' + CONFIG_PATH, '.github//docs-scope.json', '/' + CONFIG_PATH): + with self.subTest(config): + docs, jobs, out = self.scope(config=config) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('not a plain repository path', out) + + +class Jobs(Repo): + def test_a_job_runs_for_its_paths_and_skips_otherwise(self): + self.pr({'plugins/a/src/main/java/A.java': 'class A {}'}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {'plugins': 'true', 'engine': 'false'}), out) + self.assertIn('job engine: skip', out) + + def test_docs_in_a_job_folder_do_not_run_it(self): # code elsewhere makes it a full run, not an engine build + self.pr({'engine/README.md': 'y', 'plugins/a/A.java': 'class A {}'}) + self.assertEqual(self.scope()[1], {'plugins': 'true', 'engine': 'false'}) + + def test_a_file_no_job_names_runs_every_job(self): # unclear which job it reaches: all of them + for path in ('app/Main.java', '.github/workflows/ci.yml', CONFIG_PATH): + with self.subTest(path): + self.reset() + self.pr({path: json.dumps(CONFIG) + ' '}) + docs, jobs, out = self.scope() + self.assertEqual((docs, jobs), (False, {'plugins': 'true', 'engine': 'true'}), out) + self.assertIn('every job runs', out.splitlines()[0]) + + def test_a_config_change_runs_every_job_even_if_a_job_names_it(self): + cfg = {**CONFIG, 'jobs': {**CONFIG['jobs'], 'plugins': {'paths': ['plugins/**', '.github/**']}}} + self.base = self.commit('a job names .github', {CONFIG_PATH: json.dumps(cfg)}) + self.pr({CONFIG_PATH: json.dumps(cfg) + ' '}) + self.assertEqual(self.scope()[1], {'plugins': 'true', 'engine': 'true'}) + + def test_an_md_input_in_a_job_folder_runs_it(self): + self.pr({'engine/core/src/main/resources/help.md': 'y'}) + self.assertEqual(self.scope()[1]['engine'], 'true') + + def test_a_scoped_run_says_so_and_a_full_run_says_full(self): # the reason matches what the jobs do + self.pr({'engine/A.java': 'class A {}'}) + docs, jobs, out = self.scope() + self.assertEqual(jobs, {'plugins': 'false', 'engine': 'true'}, out) + self.assertTrue(out.startswith('scoped run: engine/A.java is not a docs file; only the jobs'), out) + self.reset() + self.pr({'engine/A.java': 'class A {}', 'app/Main.java': 'x'}) + docs, jobs, out = self.scope() + self.assertEqual(jobs, {'plugins': 'true', 'engine': 'true'}, out) + self.assertTrue(out.startswith('full run: app/Main.java is not a docs file and 1 more, which no job'), out) + + def test_docs_only_skips_every_job(self): + self.pr({'README.md': 'y'}) + self.assertEqual(self.scope()[:2], (True, {'plugins': 'false', 'engine': 'false'})) + + def test_a_folder_added_to_a_jobs_paths_runs_it(self): # a shared folder another job builds against + self.pr({'plugins/items/common/Item.java': 'class Item {}'}) + self.assertEqual(self.scope()[1]['engine'], 'true') + + def test_a_jobs_paths_from_a_registry_filtered_by_kind(self): # the registry names what a module compiles + for path, runs in (('plugins/locale/common/src/main/java/Wire.java', 'true'), + ('plugins/locale/common/src/main/java/Other.java', 'false'), + ('plugins/travel/common/src/main/java/a/Leg.java', 'true')): + with self.subTest(path=path): + self.reset() + self.pr({path: 'class X {}'}) + docs, jobs, out = self.scope() + self.assertEqual(jobs['engine'], runs, out) + + def test_a_registry_entry_of_another_kind_does_not_run_the_job(self): + self.pr({'plugins/site/README.md': 'y'}) # an input of a "plugin", not of an "engine-module" + self.assertEqual(self.scope()[1]['engine'], 'false') + + +class OtherEvents(Repo): + def test_a_push_with_a_base_reads_its_jobs_and_is_never_docs_only(self): + self.commit('code', {'engine/A.java': 'class A {}'}) + docs, jobs, out = self.scope('push', self.base) + self.assertEqual((docs, jobs), (False, {'plugins': 'false', 'engine': 'true'}), out) + + def test_a_docs_push_skips_every_job_but_is_not_docs_only(self): + self.commit('docs', {'plugins/README.md': 'y'}) + docs, jobs, out = self.scope('push', self.base) + self.assertEqual((docs, jobs), (False, {'plugins': 'false', 'engine': 'false'}), out) + self.assertIn('not in docs_only_events', out) + + def test_docs_only_events_can_name_push(self): + cfg = {**CONFIG, 'docs_only_events': ['pull_request', 'push']} + self.base = self.commit('push may be docs-only', {CONFIG_PATH: json.dumps(cfg)}) + self.commit('docs', {'README.md': 'y'}) + self.assertTrue(self.scope('push', self.base)[0]) + + def test_a_base_off_the_first_parent_line_is_a_full_run(self): # a merged fork PR's head was never tested + self.commit('code A (its run went red)', {'engine/A.java': 'class A {}'}) + self.git('checkout', '-q', '-b', 'fork-main') + fork_pr_head = self.commit('docs (its pull_request run passed)', {'README.md': 'y'}) + self.git('checkout', '-q', 'main') + self.git('merge', '-q', '--no-ff', '-m', 'merge the fork PR', 'fork-main') + self.commit('docs push', {'docs/a.md': 'z'}) + docs, jobs, out = self.scope('push', fork_pr_head) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('not on the first-parent line', out) + self.assertEqual(self.scope('push', self.base)[1], {'plugins': 'false', 'engine': 'true'}) # main's green + + def test_a_base_that_is_not_a_commit(self): + self.commit('docs', {'README.md': 'y'}) + docs, jobs, out = self.scope('push', '0' * 40) + self.assertEqual((docs, jobs), (False, {}), out) + self.assertIn('not a commit here', out) + + +class Globs(CleanEnv): + def test_globs(self): + sys.path.insert(0, HERE) + try: + from docs_scope import glob_re + finally: + sys.path.remove(HERE) + for pattern, yes, no in ( + ('**/*.md', ['a.md', 'x/y/a.md'], ['a.mdx', 'a.md/b']), + ('*.md', ['a.md'], ['x/a.md']), + ('**/test/**', ['test/a', 'x/test/b/c'], ['x/tests/a', 'xtest/a']), + ('src/**', ['src/a', 'src/a/b'], ['x/src/a', 'srcx/a']), + ('a?.md', ['ab.md'], ['a/.md', 'abc.md']), + ('a.b+c', ['a.b+c'], ['aXb+c']), + ('**/src/**', ['p/src/a\nb.md'], ['p/s\nrc/a']), # ** crosses a newline in a name + ('app/**', ['app/x\ny.java'], ['ap\np/x'])): + for p in yes: + self.assertTrue(glob_re(pattern).match(p), (pattern, p)) + for p in no: + self.assertFalse(glob_re(pattern).match(p), (pattern, p)) + + +class Readme(CleanEnv): + def test_the_last_green_query_takes_push_runs_only(self): # `branch` matches a fork PR's head branch "main" too + with open(README) as f: + queries = re.findall(r'/runs\?([^"\s]*)', f.read()) + self.assertTrue(queries) + for q in queries: + self.assertLessEqual({'branch=main', 'status=success', 'event=push'}, set(q.split('&')), q) + + +class Action(CleanEnv): + """action.yml runs only this folder's script (the caller's pinned SHA), never a file from the checkout.""" + text = open(ACTION).read() + + def test_runs_only_its_own_script(self): + runs = re.findall(r'^\s*run: (.*)$', self.text, re.M) + self.assertEqual(runs, ['python3 -I "$GITHUB_ACTION_PATH/docs_scope.py" --config "$CONFIG" ' + '--event "$EVENT" --base "$BASE"']) + self.assertNotIn('uses:', self.text) + self.assertIn('using: composite', self.text) + + def test_inputs_reach_the_script_by_env_only(self): # never ${{ }} inside run: (script injection) + run = re.search(r'^\s*run: (.*)$', self.text, re.M)[1] + self.assertNotIn('${{', run) + + def test_declares_every_output_the_script_writes(self): + for name in ('docs_only', 'jobs', 'reason'): + self.assertIn(f'value: ${{{{ steps.scope.outputs.{name} }}}}', self.text) + + +if __name__ == '__main__': + unittest.main(verbosity=1) diff --git a/fork-ci/actions/docs-scope/docs_scope.py b/fork-ci/actions/docs-scope/docs_scope.py new file mode 100644 index 0000000000000..31626eae2b683 --- /dev/null +++ b/fork-ci/actions/docs-scope/docs_scope.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""docs-scope: is this change docs-only, and which of the configured jobs does it reach? + +The change: + - pull_request: the union of two diffs, both without rename detection (a rename lists its old and its new path): + merge base of the base and the PR head .. the PR head (every push together, never the last one alone), and + base .. GitHub's merge commit (what merging changes on the base it was tested with). HEAD must be GitHub's merge + commit (refs/pull/N/merge): HEAD == $GITHUB_SHA, two parents, and HEAD^2 == the event's pull_request.head.sha. + Only then is HEAD^1 the base (a checkout of a PR head that merged main in would otherwise pose as one). + - any other event: --base .. HEAD, only when the caller gives --base (the last commit whose push run passed; never + github.event.before, see README), HEAD == $GITHUB_SHA and the base is on HEAD's first-parent line (a commit that + reached the branch through a merge's second parent, such as a fork PR's head, was never tested as the branch). + +A path is docs when it is a plain file (no symlink or submodule), matches a `docs` glob, is not the config file, and +matches no `never_docs` glob or `never_docs_from` entry, and its name has no control character and is UTF-8. The change +is docs-only when every path is docs and the event is in `docs_only_events`. A job runs when a path that is not docs +matches its `paths` or `paths_from`; every job runs when such a path matches no job, or when the config changed. +"Matches no job" is judged by the base's entries only: a head-only `*_from` entry can add a job, never stop a full run. + +The config is read from the BASE commit, never from the checkout, and this script runs from the action's own folder +at the caller's pinned SHA: a pull request cannot loosen the rules or the code that judge it. `*_from` files are read +at the base and the head: an entry at either makes a file not docs or runs a job, but only the base's entries can keep +a file that no job names from running every job. + +Fails closed: anything unclear, including an error this script did not foresee, means docs_only=false and every job +runs, with the reason printed. Jobs missing from the `jobs` output (config unreadable) must be read as "run": callers +test `!= 'false'`. Printed lines escape control characters, so a file name cannot inject a workflow command. + + python3 -I docs_scope.py --config .github/docs-scope.json --event "$GITHUB_EVENT_NAME" [--base SHA] +Reads $GITHUB_SHA and $GITHUB_EVENT_PATH (set by the runner). +Writes docs_only=true|false, jobs= and reason= to $GITHUB_OUTPUT. +""" +import argparse, json, os, re, subprocess, sys + +PLAIN_MODES = ('100644', '100755', '000000') # a regular file, or (000000) the side where it does not exist +KEYS = {'docs', 'never_docs', 'never_docs_from', 'docs_only_events', 'jobs'} +SOURCE_KEYS = {'file', 'list', 'where', 'field', 'label', 'why'} +ODD_NAME = re.compile(r'[\x00-\x1f\x7f-\x9f\ud800-\udfff]') # a control character, or a byte that is not UTF-8 + + +class Unclear(Exception): + """Anything that makes the answer unknown: the run is full and this is the reason.""" + + +def git(*args): + """stdout, or None when git fails. Bytes that are not UTF-8 survive as lone surrogates (never a crash).""" + r = subprocess.run(['git', *args], capture_output=True) + return r.stdout.decode('utf-8', 'surrogateescape') if r.returncode == 0 else None + + +def shown(text): + """One printable line: control characters, line separators and non-UTF-8 bytes escaped.""" + return ''.join(c if c.isprintable() else ascii(c)[1:-1] for c in text) + + +def plain_path(x): + """A repository path as git prints it: no ./, .., //, leading / or glob character (a trailing / is a folder).""" + if not isinstance(x, str) or ODD_NAME.search(x) or re.search(r'[*?\[\]{}\\]', x): + return False + return all(p not in ('', '.', '..') for p in (x[:-1] if x.endswith('/') else x).split('/')) + + +def glob_re(pattern): + """A glob from the repository root: `**/` is any number of folders (none too), `**` anything, `*` and `?` stay + inside one name.""" + i, out = 0, [] + while i < len(pattern): + if pattern.startswith('**/', i): + out.append('(?:[^/]+/)*'); i += 3 + elif pattern.startswith('**', i): + out.append('.*'); i += 2 + elif pattern[i] in '*?': + out.append('[^/]*' if pattern[i] == '*' else '[^/]'); i += 1 + else: + out.append(re.escape(pattern[i])); i += 1 + return re.compile(''.join(out) + r'\Z', re.DOTALL) # `**` crosses a newline in a name too + + +def changes(a, b, run=git): + """[(path, old mode, new mode)] between commits a and b, renames as delete + add; None on a git error.""" + # --ignore-submodules=none: a .gitmodules `ignore = all` or a runner's diff.ignoreSubmodules hides no gitlink bump + out = run('diff', '--raw', '--no-renames', '--no-abbrev', '--ignore-submodules=none', '-z', a, b) + if out is None: + return None + parts, rows = out.split('\0'), [] + for i in range(0, len(parts) - 1, 2): + meta, path = parts[i], parts[i + 1] + if not meta.startswith(':'): + return None # not the format we expect: say "unclear" + fields = meta[1:].split() + rows.append((path, fields[0], fields[1])) + return rows + + +def event_head_sha(event_path): + """pull_request.head.sha from the runner's event file; Unclear when it is not there.""" + try: + with open(event_path or '', 'rb') as f: + sha = json.load(f)['pull_request']['head']['sha'] + except (OSError, ValueError, KeyError, TypeError) as e: + raise Unclear(f'cannot read pull_request.head.sha from the event file ({type(e).__name__}): the PR is unknown') + if not isinstance(sha, str) or not re.fullmatch(r'[0-9a-f]{40,64}', sha): + raise Unclear('the event file has no pull_request.head.sha: the PR is unknown') + return sha + + +def the_change(event, head, base, run=git, sha='', event_path=''): + """(rows, the commit the config is read at, what was compared); Unclear when the change is unknown.""" + head_sha = (run('rev-parse', '-q', '--verify', f'{head}^{{commit}}') or '').strip() + if not head_sha or head_sha != sha: + raise Unclear(f'HEAD {head_sha[:10] or "?"} is not the commit this run is for ($GITHUB_SHA ' + f'{sha[:10] or "unset"}): the change is unknown') + if event == 'pull_request': + line = (run('rev-list', '--parents', '-n', '1', head) or '').split() + if len(line) != 3: + raise Unclear(f"{head} is not a two-parent merge commit (GitHub's refs/pull/N/merge): the PR diff is unknown") + _, base, pr = line + if pr != event_head_sha(event_path): + raise Unclear(f"HEAD^2 {pr[:10]} is not the event's pull_request.head.sha: HEAD is not GitHub's merge commit") + mb = (run('merge-base', base, pr) or '').strip() + if not mb: + raise Unclear(f'no merge base between the base {base[:10]} and the PR head {pr[:10]}') + pairs, what = ((mb, pr), (base, head)), f'the merge base {mb[:10]}' + elif not base: + raise Unclear(f'a {event or "unknown"} run without a base runs in full') + else: + resolved = (run('rev-parse', '-q', '--verify', f'{base}^{{commit}}') or '').strip() + if not resolved: + raise Unclear(f'the base {base} is not a commit here (shallow checkout or force push?)') + if resolved not in (run('rev-list', '--first-parent', head) or '').split(): + raise Unclear(f'the base {resolved[:10]} is not on the first-parent line of {head} (a merged branch\'s ' + f'commit, another branch, or a force push): it was never tested as this branch') + base, pairs, what = resolved, ((resolved, head),), f'the base {resolved[:10]}' + rows = [] + for a, b in pairs: + got = changes(a, b, run) + if got is None: + raise Unclear(f'git diff {a[:10]} {b[:10]} failed') + rows += got + if not rows: + raise Unclear('the change touches no file') + return rows, base, what + + +def strings(value, where): + if not isinstance(value, list) or not all(isinstance(x, str) and x for x in value): + raise Unclear(f'config: {where} must be a list of non-empty strings') + return value + + +def sources(value, where, need_why=False): + if not isinstance(value, list) or not all(isinstance(s, dict) for s in value): + raise Unclear(f'config: {where} must be a list of objects') + for s in value: + field = s.get('field') + fields = field if isinstance(field, list) else [field] + ok = (not set(s) - SOURCE_KEYS and isinstance(s.get('file'), str) and plain_path(s['file']) + and fields and all(isinstance(f, str) and f for f in fields) + and all(isinstance(s.get(k, 'x'), str) and s.get(k, 'x') for k in ('list', 'label', 'why')) + and isinstance(s.get('where', {}), dict) and (not need_why or 'why' in s)) + if not ok: + raise Unclear(f'config: {where} entry {json.dumps(s)[:80]} needs a plain file path and field (strings)' + f'{" and why" if need_why else ""}, where an object, no other keys than {sorted(SOURCE_KEYS)}') + return value + + +def load_config(path, ref, run=git): + text = run('show', f'{ref}:{path}') + if text is None: + raise Unclear(f'no config {path} at the base {ref[:10]} (a PR that adds it runs in full)') + try: + c = json.loads(text.encode('utf-8', 'surrogateescape')) + except (ValueError, RecursionError) as e: + raise Unclear(f'config {path} at {ref[:10]} is not JSON: {e}') + if not isinstance(c, dict) or set(c) - KEYS or 'docs' not in c: + raise Unclear(f'config {path}: needs "docs", and no other keys than {sorted(KEYS)}') + strings(c['docs'], 'docs') + for i, n in enumerate(c.setdefault('never_docs', [])): + if not isinstance(n, dict) or set(n) != {'glob', 'why'} or not all( + isinstance(n[k], str) and n[k] for k in ('glob', 'why')): + raise Unclear(f'config: never_docs[{i}] must be {{"glob", "why"}}, both non-empty strings') + sources(c.setdefault('never_docs_from', []), 'never_docs_from', need_why=True) + strings(c.setdefault('docs_only_events', ['pull_request']), 'docs_only_events') + jobs = c.setdefault('jobs', {}) + if not isinstance(jobs, dict): + raise Unclear('config: jobs must be an object') + for name, j in jobs.items(): + if not isinstance(j, dict) or set(j) - {'paths', 'paths_from'} or not j: + raise Unclear(f'config: jobs.{name} takes "paths" and/or "paths_from"') + strings(j.setdefault('paths', []), f'jobs.{name}.paths') + sources(j.setdefault('paths_from', []), f'jobs.{name}.paths_from') + return c + + +def read_entries(src, refs, run=git): + """[{path prefix: label} per ref, {} where the file is not]; Unclear if no ref has the file, or one is + malformed: an object without any named field, a field no kept object has (a typo; none kept), or an entry that is + not a plain path (./, /, .., a wildcard) and so could never match. Entries match a path exactly or as a folder.""" + per_ref, read = [], False + fields = src['field'] if isinstance(src['field'], list) else [src['field']] + for ref in refs: + found = {} + per_ref.append(found) + text = run('show', f'{ref}:{src["file"]}') + if text is None: + continue + try: + items = json.loads(text.encode('utf-8', 'surrogateescape')) + for key in src['list'].split('.') if 'list' in src else []: + items = items[key] + items = items if isinstance(items, list) else [items] + seen = set() + for item in items: + if any(item.get(k) != v for k, v in src.get('where', {}).items()): + continue + label = item.get(src['label'], '?') if 'label' in src else src['file'] + if not any(f in item for f in fields): + raise KeyError(f'an object ({shown(str(label))[:40]}) has none of the fields {fields}') + for f in (f for f in fields if f in item): + seen.add(f) + value = item[f] + for x in value if isinstance(value, list) else [value]: + if not isinstance(x, str) or not plain_path(x): + raise TypeError(f'{f}: {shown(repr(x))[:80]} is not a plain repository path') + found[x.rstrip('/')] = f'{label} {f}' + if set(fields) - seen: # a typo, an empty list, or a "where" that keeps nothing + raise KeyError(f'no kept object has the field(s) {sorted(set(fields) - seen)}') + except (ValueError, KeyError, IndexError, AttributeError, TypeError, RecursionError) as e: + raise Unclear(f'{src["file"]} at {ref[:10]} cannot be read as configured: {e}') + read = True + if not read: + raise Unclear(f'{src["file"]} is at neither the base nor the head: its entries are unknown') + return per_ref + + +def union(per_ref): + return {k: v for found in per_ref for k, v in found.items()} + + +def entry_match(path, entries): + for x, label in entries.items(): + if path == x or path.startswith(x + '/'): + return x, label + return None + + +def decide(event, config_path, head='HEAD', base='', run=git, sha='', event_path=''): + """(docs_only, {job: 'true'|'false'}, [reason lines]); the first line is the overall reason.""" + try: + if not plain_path(config_path) or config_path.endswith('/'): + raise Unclear(f'the config path {config_path} is not a plain repository path (no ./, .., leading /)') + rows, cbase, what = the_change(event, head, base, run, sha, event_path) + c = load_config(config_path, cbase, run) + refs = (cbase, head) + never_from = [(s, union(read_entries(s, refs, run))) for s in c['never_docs_from']] + jobs = {} # name -> (globs, entries at the base or the head (run the job), entries at the base (judge "every")) + for n, j in c['jobs'].items(): + per = [read_entries(s, refs, run) for s in j['paths_from']] + jobs[n] = ([glob_re(g) for g in j['paths']], [union(p) for p in per], [p[0] for p in per]) + except Unclear as e: + return False, {}, [f'full run: {e}'] + docs_globs = [glob_re(g) for g in c['docs']] + never = [(glob_re(n['glob']), f'{n["why"]} ({n["glob"]})') for n in c['never_docs']] + odd = {p for p, old, new in rows if old not in PLAIN_MODES or new not in PLAIN_MODES} + not_docs = {} # path -> why it is not docs + for p in sorted({p for p, _, _ in rows}): + if p in odd: + not_docs[p] = 'a symlink or submodule, not a plain file' + elif ODD_NAME.search(p): + not_docs[p] = 'a name with a control character or bytes that are not UTF-8' + elif not any(g.match(p) for g in docs_globs): + not_docs[p] = 'not a docs file' + elif p == config_path: + not_docs[p] = 'the docs-scope config' + else: + why = next((w for g, w in never if g.match(p)), None) + for s, entries in never_from: + hit = why is None and entry_match(p, entries) + if hit: + why = f'{s["why"]} ({hit[1]}: {hit[0]})' + if why: + not_docs[p] = f'a build or test input: {why}' + lines = [] + if not not_docs and event in c['docs_only_events']: + lines.append(f'docs-only: {len({p for p, _, _ in rows})} docs file(s) changed against {what}') + return True, {n: 'false' for n in jobs}, lines + [f'job {n}: skip (docs-only)' for n in jobs] + + def job_hit(p, globs, froms): + if any(g.match(p) for g in globs): + return 'its paths' + return next((f'{m[1]}: {m[0]}' for e in froms for m in [entry_match(p, e)] if m), None) + + # A file that is not docs but that no job names (a root build file, a workflow, the config): every job runs. + # "Names" by the base's rules only: the PR's own registry entries may add a job, never cancel the full run. + every = (config_path if config_path in not_docs else + next((p for p in not_docs if not any(job_hit(p, globs, base_froms) + for globs, _, base_froms in jobs.values())), None)) + more = f' and {len(not_docs) - 1} more' if len(not_docs) > 1 else '' + if every: + tail = ': every job runs' if every == config_path else ', which no job names at the base: every job runs' + lines.append(f'full run: {every} is {not_docs[every]}{more}{tail}') + elif not_docs: + first = next(iter(not_docs)) + lines.append(f'scoped run: {first} is {not_docs[first]}{more}; only the jobs these files reach run') + else: + lines.append(f'not docs-only: only docs changed, but a {event or "unknown"} run is not in docs_only_events') + out = {} + for n, (globs, froms, _) in jobs.items(): + hit = None if every else next(((p, h) for p in not_docs for h in [job_hit(p, globs, froms)] if h), None) + out[n] = 'true' if every or hit else 'false' + lines.append(f'job {n}: run (every job runs for {every})' if every else + f'job {n}: run ({hit[0]} matches {hit[1]})' if hit else + f'job {n}: skip (no changed file that is not docs matches its paths)') + return False, out, lines + + +def main(argv=None, run=git): + p = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + p.add_argument('--config', required=True) + p.add_argument('--event', required=True) + p.add_argument('--head', default='HEAD') + p.add_argument('--base', default='') + a = p.parse_args(argv) + try: + docs_only, jobs, lines = decide(a.event, a.config, a.head, a.base, run, os.environ.get('GITHUB_SHA', ''), + os.environ.get('GITHUB_EVENT_PATH', '')) + except Exception as e: # an error nobody foresaw is "unclear" too: a full run, never a crash without outputs + docs_only, jobs, lines = False, {}, [f'full run: docs-scope failed ({type(e).__name__}: {e})'] + lines = [shown(line) for line in lines] # a file name cannot start a line, so no ::workflow-command:: + print('\n'.join(lines)) + out = os.environ.get('GITHUB_OUTPUT') + if out: + with open(out, 'a') as f: + f.write(f'docs_only={"true" if docs_only else "false"}\n' + f'jobs={json.dumps(jobs, separators=(",", ":"))}\nreason={lines[0]}\n') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) From 3e2bf540950d9f37a89a07f10a4473a3cf6a3cf2 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:42:44 +0100 Subject: [PATCH 13/27] fork-ci: gradle-cache composite action (locked Maven local repository; killed runs, cache prune, release limit, repair) (#15) * fork-ci gradle-cache: port the source script, project facts as arguments (red baseline) Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: composite action; killed runs, cache prune, release limit and repair (tests) The general half of a Maven-local lock stager as fork-ci/actions/gradle-cache: - stage writes OUT whole or not at all, reads each cache entry once (a cache file another runner removes is a miss, not a red job), removes .part- files an hour old and entries no row of this lock used for cache-max-age-days. - check: every asset the lock needs is listed, finished and matches, without a download. - publish uploads again an asset of a locked row that is gone or unfinished, refuses before any upload past GitHub's 1000 assets per release, and writes each row's release so a full release is left by naming a new one. - prune-lock keeps only the named versions; it never deletes assets. - The action runs the script next to it and removes OUT only inside RUNNER_TEMP. - The fork's script tests now also find tests under fork-ci/. Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: tests for the security lens findings (red on c1d0214) Each finding of lenses S1 and S2 on PR #15 gets a test: `~` in cache-dir, publish after the release moved to a new tag, one version named twice, an unusable cache, the action's out guard. The untested protections (half-way cleanup, in-use cache entries, the action emptying out, no token on redirects) get assertions that go red when the protection is broken by hand. The fake gh now holds more than one release. Against this commit's script: 8 failures, 9 errors. Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: fix the security lens findings - cache-dir: `~` is expanded by the script (the action passes the input as written); a relative path is refused, so the cache never lands in the caller's checkout. - publish: the lock's release is always listed, so a repair or rerun of an older version after the release moved to a new tag works; the new release is created only when a new row needs it. - publish: one version named twice (or as g.a and g/a) gives one set of rows. - stage: a cache that cannot be made, read or written is a warning; the verified download is staged anyway. - action: out must be RUNNER_TEMP/gradle-cache* or inside one, so RUNNER_TEMP, the runner's own _* folders and other tools' folders are never removed. Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: tests for a lock whose rows name no release at rollover (red on ccf6682) Lens S2 round 2: with rows that carry no "release" (the source format), moving the lock's release and publishing wrote the new tag into every old row, and the repair then re-uploaded old versions into the new release. The README rollover (publish a locked version first) stays green. Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: publish refuses rows without a release whose asset is not in the lock's release Before any upload, a row without "release" whose asset is not in the lock's current release is named and publish stops, so the new tag is never written into rows whose file lives in an older release and the repair never re-uploads old versions into the new release. The README rollover now says to publish a locked version first, and a troubleshooting row covers the message. Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: format README and action.yml with vp fmt (Lint: Formatting issues found) Co-Authored-By: Claude Opus 5.5 * fork-ci gradle-cache: tests clear every GITHUB_* variable before importing the script The script reads GITHUB_API_URL at import and GITHUB_REPOSITORY as --repo's default; the test file loaded it before clearing anything, so a runner's values reached M.API. Same leak as docs-scope (fork CI run 43452154314); no test failed from it here. Co-Authored-By: Claude Opus 5.5 * fork-ci: fork-ci-tests.cjs header back to 4 lines (Lint: t3code/no-low-value-comments) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: toby --- .github/fork-ci-tests.txt | 1 + fork-ci/.gitignore | 1 + fork-ci/actions/gradle-cache/README.md | 112 +++ fork-ci/actions/gradle-cache/action.yml | 64 ++ fork-ci/actions/gradle-cache/gradle_cache.py | 435 ++++++++++ .../actions/gradle-cache/gradle_cache.test.py | 780 ++++++++++++++++++ 6 files changed, 1393 insertions(+) create mode 100644 fork-ci/.gitignore create mode 100644 fork-ci/actions/gradle-cache/README.md create mode 100644 fork-ci/actions/gradle-cache/action.yml create mode 100644 fork-ci/actions/gradle-cache/gradle_cache.py create mode 100644 fork-ci/actions/gradle-cache/gradle_cache.test.py diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 703a769df63d0..4be181a18e16a 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -16,6 +16,7 @@ .github/scripts/fork-tests-drift.test.cjs .github/scripts/fork-workflow-triggers.test.cjs fork-ci/actions/docs-scope/docs-scope.test.py +fork-ci/actions/gradle-cache/gradle_cache.test.py # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/fork-ci/.gitignore b/fork-ci/.gitignore new file mode 100644 index 0000000000000..c18dd8d83ceed --- /dev/null +++ b/fork-ci/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/fork-ci/actions/gradle-cache/README.md b/fork-ci/actions/gradle-cache/README.md new file mode 100644 index 0000000000000..19f58d48c5c1f --- /dev/null +++ b/fork-ci/actions/gradle-cache/README.md @@ -0,0 +1,112 @@ +# gradle-cache + +The Maven local repository a Gradle build reads (`-Dmaven.repo.local`), built from a reviewed lock +on every run: the same bytes on a fresh runner and on a warm one, and never an older jar from `~/.m2`. + +- **The lock** (a project file, reviewed in pull requests) lists every file by path and sha256. +- **The bytes** are assets of a GitHub release in the same repository, named `-`. + The job's own token can read them, so a runner needs no other secret. +- **The cache** is a per-runner folder of files named by sha256. It saves downloads only: the asset + list is read on every run, so a deleted asset is red on a warm runner too. A cache that cannot be + made, read or written (another user's, read-only, a full disk) is a warning, and the files are + downloaded. + +## Use in a workflow + +Pin the action by full SHA (the commit a `ci/vN` tag names): + +```yaml +- id: m2 + uses: scuttletoby/t3code/fork-ci/actions/gradle-cache@ # ci/vN + with: + lock: engine/m2-lock.json # project fact + cache-dir: ~/.cache/my-ci-m2 # optional: keep an existing warm cache +- run: ./gradlew --offline -Dmaven.repo.local="${{ steps.m2.outputs.maven-repo-local }}" build +``` + +| Input | Default | Meaning | +| -------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `lock` | required | The lock file, relative to the workspace. | +| `out` | `$RUNNER_TEMP/gradle-cache-m2` | The folder to stage into: `$RUNNER_TEMP/gradle-cache*` or a folder inside one. It is removed first. | +| `cache-dir` | `~/.cache/fork-ci-gradle-cache` | The runner's cache: an absolute path or `~/...` (expanded to the runner user's home). A relative path is refused. Runners on one machine may share it. | +| `cache-max-age-days` | `30` | An entry no row of this lock uses is removed after this many days unused. | +| `repository` | `github.repository` | Where the release is. | +| `token` | `github.token` | Needs `contents: read`. | + +Output `maven-repo-local`: the staged folder. + +## The lock + +```json +{ + "release": "m2-jars", + "files": [ + { "path": "org/example/engine/1.0/engine-1.0.jar", "sha256": "<64 hex>", "release": "m2-jars" } + ] +} +``` + +A row's `release` says which release holds its asset (default: the lock's `release`). `publish` +uploads new versions to the lock's `release` and writes a `release` into every row; a row without +one gets the lock's only when its asset is there, otherwise `publish` refuses. + +## By hand: publish, check, prune + +Run these on a machine whose `~/.m2` has the version, with `gh` logged in: + +```bash +S=fork-ci/actions/gradle-cache/gradle_cache.py # from a checkout of the pinned commit +python3 -I $S publish --lock engine/m2-lock.json --repo OWNER/REPO org.example:engine:1.1 # then commit the lock +python3 -I $S check --lock engine/m2-lock.json --repo OWNER/REPO # needs GH_TOKEN +python3 -I $S prune-lock --lock engine/m2-lock.json --keep org.example:engine:1.1 org.example:engine:1.0 +``` + +- `publish` uploads the version's jar, pom, module and `maven-metadata-local.xml` (no sources or + javadoc), then writes the rows. + - A second run changes nothing. A version named twice gets one set of rows. + - A row whose asset is gone or unfinished gets that asset uploaded again. + - A path the lock already has with other bytes is refused, because a version is never reused. +- `check` downloads nothing. Run it on a schedule, so a deleted asset shows up before a build needs it. +- `prune-lock` keeps the rows of the named versions only. Name every version a build or a rollback + may still need. It never deletes assets, so older commits still stage. + +**Never delete an asset** of a lock that any commit you may re-run still uses: that commit's stage +turns red. + +**A full release** (GitHub allows 1000 assets per release), in this order: + +1. `publish` a version the lock already has. It uploads nothing new; it writes the current + `release` into every row that has none (a lock from before per-row releases has none). +2. Set the lock's `release` to a new tag. +3. `publish` the new version. The new release is created only when a new row needs it. + +Older rows keep their `release`, and `publish` of an older version still repairs its assets in that +older release, never in the new one. If step 1 was skipped, `publish` refuses before any upload and +names the rows that have no `release` of their own; set the lock's `release` back and start at step 1. + +## Troubleshooting + +| Message | Cause | What to do | +| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `... has no asset -` | The asset was deleted, or a lock row was written without its upload. | Run `publish` for that version from a machine whose `~/.m2` has the same bytes. Or upload the runner's cached copy: `cp / - && gh release upload -`. | +| `the upload of ... never finished` | An upload was killed. | Run `publish` for that version: it replaces the asset (`--clobber`). | +| `the asset has digest ..., the lock says ...` | Other bytes were uploaded under that name. | Delete that asset, then `publish` the version from the machine that has the locked bytes. | +| `the download has sha256 ...` | As above, for an asset with no listed digest. | As above. | +| `GitHub answered 404` | The release is gone, or the token cannot read it. | Check `permissions: contents: read`. If the release is gone, recreate it with `publish` for every locked version. | +| `... is not empty` | The script was called directly on a folder that has files. | Use the action, which empties `out` first, or pass a new folder. | +| `... cannot be read` / `no "release"` / `bad path` / `listed twice` | The lock is broken. | Fix the lock in the pull request. Nothing is staged from a lock that cannot be read. | +| `GitHub allows 1000 per release (asset limit)` | The release is full. | Follow "A full release" above (publish a locked version first, then move `release`). | +| `... rows have no "release" of their own and the release ... has no asset for them` / `does not exist yet` | The lock's `release` was moved before every row had its own, or such a row's asset was deleted. `publish` cannot tell which older release holds it, so it changed nothing. | Moved: set `release` back and follow "A full release". Deleted: write `"release": ""` into those rows (the tag the asset was in), then `publish` the version to put it back. Never re-upload an old version into the new release. | +| `a version is never reused` | `~/.m2` has other bytes for a version the lock pins. | Publish a new version number. | +| `out ... must be a folder RUNNER_TEMP/gradle-cache*` | `out` is outside `RUNNER_TEMP`, is `RUNNER_TEMP` itself, or names another folder there (the runner's own `_*` folders, another tool's). | Use `$RUNNER_TEMP/gradle-cache-`, or leave `out` empty. | +| `cache dir ... must be an absolute path` | `cache-dir` is relative (it would be a folder in the checkout, wiped on every run). | Use an absolute path or `~/...`. | +| `WARN: cache ... cannot be used / read / written` | The cache dir is another user's, read-only, or the disk is full. Stage still works. | Fix the folder's owner or free space; until then every run downloads. | + +## Tests + +```bash +python3 fork-ci/actions/gradle-cache/gradle_cache.test.py +``` + +The fork's `workflows` job runs this test (`.github/fork-ci-tests.txt`). No test reaches GitHub or +runs `gh`. diff --git a/fork-ci/actions/gradle-cache/action.yml b/fork-ci/actions/gradle-cache/action.yml new file mode 100644 index 0000000000000..cf4412627b482 --- /dev/null +++ b/fork-ci/actions/gradle-cache/action.yml @@ -0,0 +1,64 @@ +name: Gradle cache (locked Maven local repository) +description: >- + Stages exactly the files a lock lists, sha256-checked, from release assets into a new folder for + `./gradlew -Dmaven.repo.local=`. A runner-local cache by sha256 saves the downloads; the + asset list is still read on every run. Usage and troubleshooting: README.md next to this file. +inputs: + lock: + description: The lock file, relative to the workspace (project fact). + required: true + out: + description: >- + The folder to stage into: RUNNER_TEMP/gradle-cache* or a folder inside one (default + RUNNER_TEMP/gradle-cache-m2). Removed first, so a rerun starts empty. + required: false + default: "" + cache-dir: + description: The runner's cache folder, absolute or ~/... (default ~/.cache/fork-ci-gradle-cache). + required: false + default: "" + cache-max-age-days: + description: Cache entries no row of this lock uses are removed after this many days unused. + required: false + default: "30" + repository: + description: The repository whose releases hold the assets. + required: false + default: ${{ github.repository }} + token: + description: A token that can read the repository's releases (contents read). + required: false + default: ${{ github.token }} +outputs: + maven-repo-local: + description: The staged folder, for -Dmaven.repo.local. + value: ${{ steps.stage.outputs.dir }} +runs: + using: composite + steps: + - id: stage + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + LOCK: ${{ inputs.lock }} + OUT: ${{ inputs.out }} + CACHE_DIR: ${{ inputs.cache-dir }} + MAX_AGE: ${{ inputs.cache-max-age-days }} + REPO: ${{ inputs.repository }} + GH_TOKEN: ${{ inputs.token }} + # The script is the copy next to this file (the pinned action), never one from the caller's + # workspace. OUT is removed only if it is RUNNER_TEMP/gradle-cache* or inside one: never RUNNER_TEMP, the + # runner's own _* folders there (GITHUB_OUTPUT, GITHUB_ENV) or another tool's folder. + run: | + set -euo pipefail + temp=$(realpath -m -- "$RUNNER_TEMP") + out=$(realpath -m -- "${OUT:-$RUNNER_TEMP/gradle-cache-m2}") + case "$out" in + "$temp"/gradle-cache*) ;; + *) echo "::error::gradle-cache: out ($out) must be a folder RUNNER_TEMP/gradle-cache* or one inside it (RUNNER_TEMP is $temp)"; exit 1 ;; + esac + rm -rf -- "$out" + args=(stage --lock "$LOCK" --out "$out" --cache-max-age-days "$MAX_AGE" --repo "$REPO") + if [ -n "$CACHE_DIR" ]; then args+=(--cache "$CACHE_DIR"); fi + python3 -I "$ACTION_PATH/gradle_cache.py" "${args[@]}" + echo "dir=$out" >> "$GITHUB_OUTPUT" diff --git a/fork-ci/actions/gradle-cache/gradle_cache.py b/fork-ci/actions/gradle-cache/gradle_cache.py new file mode 100644 index 0000000000000..50d00245acb4f --- /dev/null +++ b/fork-ci/actions/gradle-cache/gradle_cache.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""A Gradle build's Maven local repository from a reviewed lock: the same bytes on every runner. + + gradle_cache.py stage --lock FILE --out DIR every file the lock lists, sha256-checked, in a new DIR; then + `./gradlew -Dmaven.repo.local=DIR`. Nothing else is in DIR, so a + jar the lock does not list fails in Gradle; a listed one that is + missing or has other bytes fails here, by name. + gradle_cache.py check --lock FILE every asset the lock needs is listed, finished and has the locked + digest (no download): run it on a schedule to see a deleted asset + before a build needs it. + gradle_cache.py publish --lock FILE G:A:V ... by hand, after the version is in ~/.m2: uploads its jar, pom, + module and SNAPSHOT metadata to the lock's release and adds their + rows. A path the lock has with other bytes is refused (a version + is never reused); an asset of a locked row that is gone or + unfinished is uploaded again; a second run changes nothing. + gradle_cache.py prune-lock --lock FILE --keep G:A:V ... drops the rows of every other version. Never deletes assets: + old commits still stage. + +The lock: {"release": "", "files": [{"path": "", "sha256": "", "release": ""}]}. +A row's "release" (default: the lock's) holds its asset `-`; publish writes to the lock's "release", +so a full release is left by naming a new one there. Assets are read with the job's own token (contents: read). +stage keeps a cache by sha256 and still asks GitHub for the asset list on every run, so a runner with a warm cache and a +fresh runner give the same result. Troubleshooting: README.md next to this file. +""" +import argparse, hashlib, json, os, re, shutil, subprocess, sys, tempfile, time, urllib.error, urllib.request + +API = os.environ.get('GITHUB_API_URL') or 'https://api.github.com' +CACHE = os.path.join(os.environ.get('XDG_CACHE_HOME') or os.path.expanduser('~/.cache'), 'fork-ci-gradle-cache') +M2 = os.path.expanduser('~/.m2/repository') +MAX_ASSETS = 1000 # GitHub's limit per release (docs: "About releases") +PART_AGE = 3600 # a .part- file is written and renamed in one go, so one an hour old is a killed run's +PATH_OK = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*)+$') +SHA_OK = re.compile(r'^[0-9a-f]{64}$') +TAG_OK = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*$') +# What a build reads from a version folder. Sources and javadoc jars are for IDEs; the folder-level metadata is for ranges. +WANTED = re.compile(r'\.(jar|pom|module)$|^maven-metadata-local\.xml$') +NOT_WANTED = re.compile(r'-(sources|javadoc)\.jar$') + + +class Refused(Exception): + pass + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def asset_name(row): + return f"{row['sha256']}-{os.path.basename(row['path'])}" + + +def release_of(lock, row): + return row.get('release') or lock['release'] + + +def row_problems(row): + """What read_lock refuses in one row. publish checks its new rows with the same rule before the first upload, so it + can never write a row that turns the whole lock (and every stage) red.""" + path, digest, problems = row.get('path', ''), row.get('sha256', ''), [] + if not PATH_OK.match(path) or '..' in path.split('/'): + problems.append(f'bad path {path!r}') + if not SHA_OK.match(digest): + problems.append(f'{path}: bad sha256 {digest!r}') + if 'release' in row and not TAG_OK.match(str(row['release'])): + problems.append(f'{path}: bad release {row["release"]!r}') + return problems + + +def read_lock(lock_file): + try: + with open(lock_file, encoding='utf-8') as f: + lock = json.load(f) + except (OSError, ValueError) as e: + raise Refused(f'{lock_file} cannot be read: {e}') + if not isinstance(lock, dict) or not TAG_OK.match(str(lock.get('release', ''))): + raise Refused(f'{lock_file}: no "release" (the release tag that holds the assets)') + problems, seen = [], set() + for row in lock.setdefault('files', []): + path = row.get('path', '') + problems += row_problems(row) + if path in seen: + problems.append(f'{path} is listed twice') + seen.add(path) + if problems: + raise Refused(f'{lock_file}: ' + '; '.join(problems)) + return lock + + +def write_lock(lock, lock_file): + lock['files'].sort(key=lambda r: r['path']) + with open(lock_file + '.tmp', 'w', encoding='utf-8') as f: + f.write(json.dumps(lock, indent=1) + '\n') + os.replace(lock_file + '.tmp', lock_file) + + +class GitHub: + """The two calls stage and check make, with the job's token. Authorization is never sent on to the redirect.""" + def __init__(self, token, repo): + if not token: + raise Refused('no GH_TOKEN or GITHUB_TOKEN: stage asks GitHub for the asset list on every run') + self.token, self.repo = token, repo + + def _get(self, url, accept): + req = urllib.request.Request(url, headers={'Accept': accept, 'User-Agent': 'fork-ci-gradle-cache'}) + req.add_unredirected_header('Authorization', f'Bearer {self.token}') + with urllib.request.urlopen(req, timeout=120) as r: + return r.read() + + def assets(self, tag): + rel = json.loads(self._get(f'{API}/repos/{self.repo}/releases/tags/{tag}', 'application/vnd.github+json')) + out, page = {}, 1 + while True: + got = json.loads(self._get(f'{API}/repos/{self.repo}/releases/{rel["id"]}/assets?per_page=100&page={page}', + 'application/vnd.github+json')) + out.update({a['name']: a for a in got}) + if len(got) < 100: + return out + page += 1 + + def download(self, asset): + return self._get(asset['url'], 'application/octet-stream') + + +def listed_assets(lock, rows, gh): + """{release: {asset name: asset}} for every release a row names, and what is wrong with the rows' assets.""" + assets = {tag: gh.assets(tag) for tag in sorted({release_of(lock, r) for r in rows})} + problems = [] + for row in rows: + tag = release_of(lock, row) + a = assets[tag].get(asset_name(row)) + if a is None: + problems.append(f'{row["path"]}: the release {tag} has no asset {asset_name(row)} ' + '(put it back: gradle_cache.py publish; README.md "Troubleshooting")') + elif a.get('state', 'uploaded') != 'uploaded': + problems.append(f'{row["path"]}: the upload of {asset_name(row)} to {tag} never finished (state ' + f'{a["state"]}; gradle_cache.py publish uploads it again)') + elif a.get('digest') and a['digest'] != 'sha256:' + row['sha256']: + problems.append(f'{row["path"]}: the asset has digest {a["digest"]}, the lock says sha256:{row["sha256"]}') + return assets, problems + + +def prune_cache(cache, keep, max_age_days, say, now): + """#1330 and #1324: remove a killed run's .part- files and entries no lock row here used for max_age_days. An entry + another runner removes while this run reads it is a cache miss there, so removing is never unsafe.""" + removed, unknown = 0, [] + for name in sorted(os.listdir(cache)): + path = os.path.join(cache, name) + try: + age = now - os.lstat(path).st_mtime + except FileNotFoundError: + continue + if name.startswith('.part-'): + stale = age > PART_AGE + elif SHA_OK.match(name): + stale = name not in keep and age > max_age_days * 86400 + else: + unknown.append(name) + continue + if stale: + try: + os.remove(path) + removed += 1 + except FileNotFoundError: + pass + except OSError as e: + say(f'WARN: cannot remove {path}: {e}') + if removed: + say(f'cache {cache}: removed {removed} files (killed downloads, or unused for {max_age_days} days)') + if unknown: + say(f'cache {cache}: left {len(unknown)} files this tool did not write: {", ".join(unknown[:5])}') + + +def cached(cache, row, say): + """The verified bytes of a cache entry, or None (a miss). Read once: the bytes staged are the bytes checked.""" + path = os.path.join(cache, row['sha256']) + try: + with open(path, 'rb') as f: + data = f.read() + except FileNotFoundError: + return None + if sha256(data) != row['sha256']: + say(f'WARN: {path} does not match its sha256; downloading it again') + return None + try: + os.utime(path) # its age restarts: prune_cache keeps what any lock still uses + except OSError: + pass + return data + + +def cache_dir(path): + """The action passes cache-dir as written, so `~` is expanded here. Any other relative path is refused: it would be a + folder in the caller's checkout, which the checkout step wipes, so the cache would never be warm.""" + full = os.path.expanduser(path) + if not os.path.isabs(full): + raise Refused(f'cache dir {path!r} must be an absolute path or start with ~/ (a relative one lands in the checkout)') + return full + + +def write_atomic(dest_dir, name, data): + fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix='.part-') + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + os.replace(tmp, os.path.join(dest_dir, name)) + except BaseException: + os.unlink(tmp) + raise + + +def stage(lock_file, out, gh, cache=CACHE, max_age_days=30, say=print, now=None): + """OUT appears whole or not at all: the files go into a sibling folder that is renamed to OUT at the end.""" + lock = read_lock(lock_file) + rows = lock['files'] + if not rows: + raise Refused(f'{lock_file} lists no files') + if os.path.exists(out) and os.listdir(out): + raise Refused(f'{out} is not empty: stage writes only into an empty folder, so nothing old can stand in') + if max_age_days < 1: + raise Refused(f'cache max age {max_age_days} days: at least 1') + assets, problems = listed_assets(lock, rows, gh) + if problems: + raise Refused('\n'.join(problems)) + try: # the cache saves downloads only: one that cannot be made, read or written is a warning, never a red stage + os.makedirs(cache, exist_ok=True) + prune_cache(cache, {r['sha256'] for r in rows}, max_age_days, say, time.time() if now is None else now) + except OSError as e: + say(f'WARN: cache {cache} cannot be used ({e}); every file is downloaded') + cache = None + out = os.path.abspath(out) + os.makedirs(os.path.dirname(out), exist_ok=True) + building = tempfile.mkdtemp(dir=os.path.dirname(out), prefix=f'.{os.path.basename(out)}.part-') + downloaded = 0 + try: + for row in rows: + data = None + if cache: + try: + data = cached(cache, row, say) + except OSError as e: + say(f'WARN: cache {cache} cannot be read ({e}); the rest is downloaded and not cached') + cache = None + if data is None: + data = gh.download(assets[release_of(lock, row)][asset_name(row)]) + if sha256(data) != row['sha256']: + raise Refused(f'{row["path"]}: the download has sha256 {sha256(data)}, the lock says {row["sha256"]}') + downloaded += 1 + if cache: + try: + write_atomic(cache, row['sha256'], data) + except OSError as e: + say(f'WARN: cache {cache} cannot be written ({e}); the rest is downloaded and not cached') + cache = None + dest = os.path.join(building, *row['path'].split('/')) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, 'wb') as f: + f.write(data) + if os.path.isdir(out): + os.rmdir(out) # empty, checked above + os.rename(building, out) + except BaseException: + shutil.rmtree(building, ignore_errors=True) + raise + say(f'staged {len(rows)} files from {lock_file} in {out} ({downloaded} downloaded, {len(rows) - downloaded} from the cache)') + + +def check(lock_file, gh, say=print): + lock = read_lock(lock_file) + assets, problems = listed_assets(lock, lock['files'], gh) + if problems: + raise Refused('\n'.join(problems)) + for tag, listed in assets.items(): + say(f'release {tag}: {len(listed)} of {MAX_ASSETS} assets') + say(f'{lock_file}: all {len(lock["files"])} assets are listed, finished and match') + + +def version_dir(coord): + parts = coord.split(':') + if len(parts) != 3 or not all(parts): + raise Refused(f'{coord!r} is not group:artifact:version') + group, artifact, version = parts + return '/'.join([*group.split('.'), artifact, version]) + + +def run_gh(cmd): + if os.environ.get('FORK_CI_TEST_MODE'): + raise Refused('a test reached the real gh runner') + return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout + + +def release_assets(run, repo, tag): + """{asset name: {name, state, size, digest}} of the release, every page; None when it does not exist.""" + try: + rel = json.loads(run(['gh', 'api', f'repos/{repo}/releases/tags/{tag}'])) + except subprocess.CalledProcessError as e: + if 'HTTP 404' in (e.stderr or ''): + return None + raise Refused(f'gh api releases/tags/{tag} failed: {(e.stderr or "").strip()[:300]}') + lines = run(['gh', 'api', '--paginate', f'repos/{repo}/releases/{rel["id"]}/assets?per_page=100', + '--jq', '.[] | {name, state, size, digest}']) + return {a['name']: a for a in map(json.loads, filter(None, lines.splitlines()))} + + +def publish(lock_file, coords, repo, m2=M2, run=None, say=print, max_assets=MAX_ASSETS): + """Upload first, then the lock: a crash in between leaves assets nobody pins, and the next run only writes the rows. + Every row is written with its release, so a later change of the lock's "release" moves only new uploads.""" + run = run or run_gh + lock = read_lock(lock_file) + tag = lock['release'] + have = {r['path']: r for r in lock['files']} + want, seen = [], set() # (row, source file, size): every build file of the versions, locked or new, once + for coord in coords: + rel = version_dir(coord) + folder = os.path.join(m2, *rel.split('/')) + names = sorted(n for n in (os.listdir(folder) if os.path.isdir(folder) else []) + if WANTED.search(n) and not NOT_WANTED.search(n)) + if not any(n.endswith('.jar') for n in names) or not any(n.endswith('.pom') for n in names): + raise Refused(f'{folder} has no jar and pom: publish {coord} to ~/.m2 first') + for n in names: + with open(os.path.join(folder, n), 'rb') as f: + data = f.read() + row = {'path': f'{rel}/{n}', 'sha256': sha256(data), 'release': tag} + if row['path'] in seen: # a version named twice (or as g.a and g/a): one row, never two that break the lock + continue + seen.add(row['path']) + old = have.get(row['path']) + if old and old['sha256'] != row['sha256']: + raise Refused(f'{row["path"]} is in the lock with other bytes ({old["sha256"][:16]}, ~/.m2 has ' + f'{row["sha256"][:16]}): a version is never reused; publish a new version') + want.append((dict(row, release=release_of(lock, old)) if old else row, os.path.join(folder, n), len(data))) + problems = [p for row, _, _ in want for p in row_problems(row)] + if problems: + raise Refused('the lock could not read these rows, so nothing is uploaded: ' + '; '.join(problems) + + ' (a version takes only [A-Za-z0-9._-])') + listed = {} # the lock's release too: after it moved to a new tag, a repair of an older row still runs + for t in sorted({row['release'] for row, _, _ in want} | {tag}): + listed[t] = release_assets(run, repo, t) + if listed[t] is None and t != tag: + raise Refused(f'locked rows name the release {t}, which is gone: restore it by hand (README.md)') + # A row without "release" is read from the lock's, and publish writes that tag into it. That is only true when its + # asset is in the lock's release now: after the lock moved to a new tag the asset is in an older one, and which one + # cannot be known here, so nothing is guessed, uploaded or written (lens S2 round 2). + unnamed = [r['path'] for r in lock['files'] if 'release' not in r and asset_name(r) not in (listed[tag] or {})] + if unnamed: + raise Refused(f'{len(unnamed)} locked rows have no "release" of their own and the release {tag} ' + f'{"has no asset for them" if listed[tag] is not None else "does not exist yet"}: ' + f'{", ".join(unnamed[:10])}{" ..." if len(unnamed) > 10 else ""}. Write into each row the tag ' + 'that holds its asset ("release": ""), or set the lock\'s "release" back and publish a ' + 'locked version first (README.md "A full release"). Nothing was uploaded and the lock is unchanged') + uploads = [] + for row, src, size in want: + a = (listed[row['release']] or {}).get(asset_name(row)) + good = a and a.get('state', 'uploaded') == 'uploaded' and a.get('size', size) == size and \ + a.get('digest') in (None, 'sha256:' + row['sha256']) + if not good: + uploads.append((row, src, a is not None)) + for t in sorted({row['release'] for row, _, _ in uploads}): + count = len(listed[t] or {}) + adds = sum(1 for row, _, clobber in uploads if row['release'] == t and not clobber) + if count + adds > max_assets: + raise Refused(f'the release {t} has {count} assets and this publish adds {adds}: GitHub allows {max_assets} ' + f'per release (asset limit). Set "release" in {lock_file} to a new tag; rows keep theirs') + if listed[tag] is None and any(row['release'] == tag for row, _, _ in uploads): + run(['gh', 'release', 'create', tag, '-R', repo, '--prerelease', '--latest=false', '--title', 'CI: Maven local jars', + '--notes', 'The jars a Gradle build takes from a Maven local repository, named -. ' + 'Pinned by a lock file; written only by fork-ci/actions/gradle-cache/gradle_cache.py publish.']) + with tempfile.TemporaryDirectory() as d: + for row, src, clobber in uploads: + named = os.path.join(d, asset_name(row)) + shutil.copyfile(src, named) + run(['gh', 'release', 'upload', row['release'], '-R', repo, *(['--clobber'] if clobber else []), named]) + say(f'uploaded {asset_name(row)} to {row["release"]}' + (' (replaced an unfinished or other one)' if clobber else '')) + new = [row for row, _, _ in want if row['path'] not in have] + for row in lock['files']: + row.setdefault('release', tag) + lock['files'].extend(new) + write_lock(lock, lock_file) + say(f'{lock_file}: {len(new)} new rows, {len(uploads)} uploads' + ('' if new or uploads else ' (already complete)')) + + +def prune_lock(lock_file, keep, say=print): + lock = read_lock(lock_file) + dirs = {version_dir(c): c for c in keep} + present = {r['path'].rsplit('/', 1)[0] for r in lock['files']} + missing = [c for d, c in dirs.items() if d not in present] + if missing or not dirs: + raise Refused(f'--keep {", ".join(missing) or "(nothing)"}: the lock has no rows for it, so nothing is dropped') + dropped = sorted({r['path'].rsplit('/', 1)[0] for r in lock['files']} - set(dirs)) + lock['files'] = [r for r in lock['files'] if r['path'].rsplit('/', 1)[0] in dirs] + write_lock(lock, lock_file) + say(f'{lock_file}: dropped {len(dropped)} versions ({", ".join(dropped) or "none"}); their assets stay in the release') + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest='cmd', required=True) + for name in ('stage', 'check', 'publish', 'prune-lock'): + p = sub.add_parser(name) + p.add_argument('--lock', required=True) + if name != 'prune-lock': + p.add_argument('--repo', default=os.environ.get('GITHUB_REPOSITORY')) + sub.choices['stage'].add_argument('--out', required=True) + sub.choices['stage'].add_argument('--cache', default=CACHE) + sub.choices['stage'].add_argument('--cache-max-age-days', type=int, default=30) + sub.choices['publish'].add_argument('--m2', default=M2, help='the Maven local repository (default ~/.m2/repository)') + sub.choices['publish'].add_argument('coords', nargs='+') + sub.choices['prune-lock'].add_argument('--keep', nargs='+', required=True) + args = ap.parse_args(argv) + try: + if args.cmd != 'prune-lock' and not args.repo: + raise Refused('no --repo and no GITHUB_REPOSITORY') + token = os.environ.get('GH_TOKEN') or os.environ.get('GITHUB_TOKEN') + if args.cmd == 'stage': + stage(args.lock, args.out, GitHub(token, args.repo), cache=cache_dir(args.cache), + max_age_days=args.cache_max_age_days) + elif args.cmd == 'check': + check(args.lock, GitHub(token, args.repo)) + elif args.cmd == 'publish': + publish(args.lock, args.coords, args.repo, m2=args.m2) + else: + prune_lock(args.lock, args.keep) + except Refused as e: + print(f'::error::gradle-cache {args.cmd}: {e}', file=sys.stderr) + return 1 + except urllib.error.HTTPError as e: # the release or an asset is gone, or the token cannot read it + print(f'::error::gradle-cache {args.cmd}: GitHub answered {e.code} for {e.url}', file=sys.stderr) + return 1 + except (urllib.error.URLError, OSError, subprocess.CalledProcessError) as e: + print(f'::error::gradle-cache {args.cmd}: {e}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/fork-ci/actions/gradle-cache/gradle_cache.test.py b/fork-ci/actions/gradle-cache/gradle_cache.test.py new file mode 100644 index 0000000000000..979f704feaed1 --- /dev/null +++ b/fork-ci/actions/gradle-cache/gradle_cache.test.py @@ -0,0 +1,780 @@ +"""gradle_cache.py: a Maven local repository only from the lock, the same bytes on every runner, never an older jar. + + python3 fork-ci/actions/gradle-cache/gradle_cache.test.py + +No test reaches GitHub or runs gh: stage and check get a fake client, publish a fake `run`, and the real runner refuses. +The `Issue*` classes were red on the plain port of the source script; each names what it guards. +""" +import contextlib, errno, hashlib, http.server, importlib.util, io, json, os, re, shutil, subprocess, tempfile, textwrap, \ + threading, time, unittest +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +# No GITHUB_* variable (and no token) from the runner that runs these tests: the script reads GITHUB_API_URL at import +# and GITHUB_REPOSITORY as --repo's default, and a test that wants one sets it. Cleared before the import. +for name in [k for k in os.environ if k.startswith('GITHUB_')] + ['GH_TOKEN']: + os.environ.pop(name, None) +os.environ['FORK_CI_TEST_MODE'] = '1' # run_gh refuses +spec = importlib.util.spec_from_file_location('gradle_cache', os.path.join(HERE, 'gradle_cache.py')) +M = importlib.util.module_from_spec(spec) +spec.loader.exec_module(M) + +JAR, POM = b'jar bytes', b'' +V = 'org/example/engine/1.0' +DAY = 86400 + + +def sha(b): + return hashlib.sha256(b).hexdigest() + + +class FakeGitHub: + def __init__(self, files, tag='jars'): # {asset name: bytes} in release `tag` + self.releases, self.downloads, self.listed = {tag: dict(files)}, [], [] + + @property + def files(self): + return next(iter(self.releases.values())) + + def assets(self, tag): + self.listed.append(tag) + if tag not in self.releases: + raise M.Refused(f'no release {tag}') + return {n: {'name': n, 'url': f'{tag}/{n}', 'digest': 'sha256:' + sha(b), 'state': 'uploaded'} + for n, b in self.releases[tag].items()} + + def download(self, asset): + self.downloads.append(asset['name']) + tag, name = asset['url'].split('/', 1) + return self.releases[tag][name] + + +class Base(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp(prefix='gradle-cache-test-') + self.addCleanup(shutil.rmtree, self.root, True) + self.out, self.cache, self.m2 = (os.path.join(self.root, n) for n in ('out', 'cache', 'm2')) + self.lock_file = os.path.join(self.root, 'm2-lock.json') + self.lines = [] + + def lock(self, rows, release='jars'): + with open(self.lock_file, 'w') as f: + json.dump({'release': release, 'files': rows}, f) + + def read_lock(self): + with open(self.lock_file) as f: + return json.load(f) + + def stage(self, gh, out=None): + M.stage(self.lock_file, out or self.out, gh, cache=self.cache, say=self.lines.append) + + def rows(self): + return [{'path': f'{V}/engine-1.0.jar', 'sha256': sha(JAR)}, {'path': f'{V}/engine-1.0.pom', 'sha256': sha(POM)}] + + def remote(self): + return FakeGitHub({f'{sha(JAR)}-engine-1.0.jar': JAR, f'{sha(POM)}-engine-1.0.pom': POM}) + + def staged(self, out=None): + out = out or self.out + return sorted(os.path.relpath(os.path.join(d, f), out) for d, _, fs in os.walk(out) for f in fs) + + +class Stage(Base): + def test_a_fresh_runner_gets_exactly_the_locked_files(self): + self.lock(self.rows()) + self.stage(self.remote()) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + with open(os.path.join(self.out, V, 'engine-1.0.jar'), 'rb') as f: + self.assertEqual(f.read(), JAR) + + def test_a_second_run_takes_the_cache_and_still_asks_github(self): + self.lock(self.rows()) + self.stage(self.remote()) + shutil.rmtree(self.out) + gh = self.remote() + self.stage(gh) + self.assertEqual(gh.downloads, []) + gone = self.remote(); del gone.files[f'{sha(POM)}-engine-1.0.pom'] + shutil.rmtree(self.out) # a warm cache never hides an asset that is gone: the fresh runner would be red + with self.assertRaisesRegex(M.Refused, 'engine-1.0.pom: the release jars has no asset'): + self.stage(gone) + + def test_a_missing_asset_is_red_by_name_and_nothing_is_staged(self): + self.lock(self.rows()) + gh = self.remote(); del gh.files[f'{sha(JAR)}-engine-1.0.jar'] + with self.assertRaisesRegex(M.Refused, r'engine-1\.0\.jar: the release jars has no asset'): + self.stage(gh) + self.assertFalse(os.path.exists(self.out)) + + def test_other_bytes_under_the_asset_name_are_red_and_never_cached(self): + self.lock(self.rows()) + gh = self.remote() + gh.files[f'{sha(JAR)}-engine-1.0.jar'] = b'an older jar' + real = gh.assets + gh.assets = lambda tag: {n: {k: v for k, v in a.items() if k != 'digest'} for n, a in real(tag).items()} + with self.assertRaisesRegex(M.Refused, 'the download has sha256'): + self.stage(gh) + self.assertFalse(os.path.exists(os.path.join(self.cache, sha(JAR)))) + self.assertFalse(os.path.exists(self.out)) + + def test_a_listed_digest_that_differs_is_red_before_any_download(self): + self.lock(self.rows()) + gh = self.remote() + real = gh.assets + gh.assets = lambda tag: {n: dict(a, digest='sha256:' + '0' * 64) for n, a in real(tag).items()} + with self.assertRaisesRegex(M.Refused, 'the asset has digest'): + self.stage(gh) + self.assertEqual(gh.downloads, []) + + def test_a_damaged_cache_file_is_named_and_downloaded_again(self): + self.lock(self.rows()) + os.makedirs(self.cache) + with open(os.path.join(self.cache, sha(JAR)), 'wb') as f: + f.write(b'cut off') + gh = self.remote() + self.stage(gh) + self.assertEqual(sorted(gh.downloads), sorted([f'{sha(JAR)}-engine-1.0.jar', f'{sha(POM)}-engine-1.0.pom'])) + self.assertTrue(any(line.startswith('WARN:') and sha(JAR) in line for line in self.lines)) + with open(os.path.join(self.cache, sha(JAR)), 'rb') as f: + self.assertEqual(f.read(), JAR) + + def test_a_folder_with_files_is_refused(self): + self.lock(self.rows()) + os.makedirs(os.path.join(self.out, V)) + open(os.path.join(self.out, V, 'engine-0.9.jar'), 'w').close() + with self.assertRaisesRegex(M.Refused, 'is not empty'): + self.stage(self.remote()) + + def test_bad_locks_are_refused(self): + for rows, why in (([{'path': '../x/y.jar', 'sha256': sha(JAR)}], 'bad path'), + ([{'path': f'{V}/a.jar', 'sha256': 'abc'}], 'bad sha256'), + (self.rows() + self.rows()[:1], 'listed twice'), ([], 'lists no files')): + self.lock(rows) + with self.subTest(why), self.assertRaisesRegex(M.Refused, why): + self.stage(self.remote()) + for text, why in (('{"files": [', 'cannot be read'), ('{"files": []}', 'no "release"'), ('', 'cannot be read')): + with open(self.lock_file, 'w') as f: + f.write(text) + with self.subTest(why), self.assertRaisesRegex(M.Refused, why): + self.stage(self.remote()) + self.assertFalse(os.path.exists(self.out)) + + def test_two_runners_at_once_share_one_cache(self): + self.lock(self.rows()) + errors = [] + + def one(out): + try: + M.stage(self.lock_file, out, self.remote(), cache=self.cache, say=lambda _: None) + except BaseException as e: # noqa: BLE001 (reported below) + errors.append(e) + threads = [threading.Thread(target=one, args=(os.path.join(self.root, f'out{i}'),)) for i in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(errors, []) + for i in range(4): + self.assertEqual(self.staged(os.path.join(self.root, f'out{i}')), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + self.assertEqual(sorted(os.listdir(self.cache)), sorted([sha(JAR), sha(POM)])) + + +class PublishBase(Base): + def setUp(self): + super().setUp() + folder = os.path.join(self.m2, *V.split('/')) + os.makedirs(folder) + for name, data in (('engine-1.0.jar', JAR), ('engine-1.0.pom', POM), ('engine-1.0-sources.jar', b's'), + ('engine-1.0-javadoc.jar', b'j'), ('_remote.repositories', b'r')): + with open(os.path.join(folder, name), 'wb') as f: + f.write(data) + self.uploaded, self.release, self.calls = [], {}, [] + self.states = {} # asset name -> state other than 'uploaded' + self.exists = True + self.others, self.created = {}, [] # releases other than `jars`; the tags `release create` made + + def gh(self, cmd): + """A stand-in for gh: the release `jars` of example/repo in self.release; any other tag in self.others.""" + self.calls.append(cmd) + if cmd[:3] == ['gh', 'release', 'view']: # the port's listing call + if not self.exists: + raise subprocess.CalledProcessError(1, cmd, stderr='release not found') + return json.dumps({'assets': [{'name': n} for n in self.release]}) + tags = ['jars'] + sorted(self.others) + if cmd[:2] == ['gh', 'api'] and '/releases/tags/' in cmd[2]: + t = cmd[2].rsplit('/', 1)[1] + if (t == 'jars' and not self.exists) or t not in tags: + raise subprocess.CalledProcessError(1, cmd, stderr='gh: Not Found (HTTP 404)') + return json.dumps({'id': 7 + tags.index(t)}) + if cmd[:3] == ['gh', 'api', '--paginate'] and '/releases/' in cmd[3]: + assets = self.assets_of(tags[int(cmd[3].split('/releases/')[1].split('/')[0]) - 7]) + return ''.join(json.dumps({'name': n, 'size': len(b), 'digest': 'sha256:' + sha(b), + 'state': self.states.get(n, 'uploaded')}) + '\n' for n, b in assets.items()) + if cmd[:3] == ['gh', 'release', 'create']: + if cmd[3] == 'jars': + self.exists = True + else: + self.others.setdefault(cmd[3], {}) + self.created.append(cmd[3]) + return '' + if cmd[:3] == ['gh', 'release', 'upload']: + name, assets = os.path.basename(cmd[-1]), self.assets_of(cmd[3]) + if name in assets and '--clobber' not in cmd: + raise subprocess.CalledProcessError(1, cmd, stderr='asset under the same name already exists') + with open(cmd[-1], 'rb') as f: + assets[name] = f.read() + self.states.pop(name, None) + self.uploaded.append(name if cmd[3] == 'jars' else f'{cmd[3]}/{name}') + return '' + raise AssertionError(cmd) + + def assets_of(self, tag): + return self.release if tag == 'jars' else self.others[tag] + + def publish(self, run=None, coords=('org.example:engine:1.0',), **kw): + M.publish(self.lock_file, list(coords), 'example/repo', m2=self.m2, run=run or self.gh, say=self.lines.append, **kw) + + def paths(self): + return [(r['path'], r['sha256']) for r in self.read_lock()['files']] + + +class Publish(PublishBase): + + def test_publish_uploads_the_build_files_and_a_second_run_changes_nothing(self): + self.lock([]) + self.exists = False + self.publish() + self.assertEqual(sorted(self.uploaded), sorted([f'{sha(JAR)}-engine-1.0.jar', f'{sha(POM)}-engine-1.0.pom'])) + self.assertEqual(self.paths(), [(r['path'], r['sha256']) for r in self.rows()]) + self.uploaded.clear() + self.publish() + self.assertEqual(self.uploaded, []) + self.assertEqual(self.paths(), [(r['path'], r['sha256']) for r in self.rows()]) + self.stage(FakeGitHub(self.release)) # what publish wrote is what stage takes + + def test_a_crash_after_the_upload_is_finished_by_the_next_run(self): + self.lock([]) + + def dies_after_first_upload(cmd): + out = self.gh(cmd) + if cmd[:3] == ['gh', 'release', 'upload']: + raise KeyboardInterrupt + return out + with self.assertRaises(KeyboardInterrupt): + self.publish(dies_after_first_upload) + self.assertEqual(self.read_lock()['files'], []) # nothing pins a half-published version + self.publish() + self.assertEqual(self.paths(), [(r['path'], r['sha256']) for r in self.rows()]) + self.assertEqual(len(self.release), 2) + + def test_other_bytes_under_a_locked_path_are_refused_and_the_lock_stays(self): + self.lock([{'path': f'{V}/engine-1.0.jar', 'sha256': sha(b'the first build')}]) + with self.assertRaisesRegex(M.Refused, 'never reused'): + self.publish() + self.assertEqual(self.uploaded, []) + self.assertEqual(len(self.read_lock()['files']), 1) + + def test_a_version_not_in_m2_is_refused(self): + self.lock([]) + with self.assertRaisesRegex(M.Refused, 'has no jar and pom'): + self.publish(coords=['org.example:engine:2.0']) + + def test_a_path_the_lock_could_not_read_is_refused_before_any_upload(self): + # source #1257 r1: Maven's 1.0+b1 was uploaded and written, then the lock refused itself (every stage red) + self.lock([]) + folder = os.path.join(self.m2, 'org', 'example', 'engine', '1.0+b1') + os.makedirs(folder) + for name in ('engine-1.0+b1.jar', 'engine-1.0+b1.pom'): + with open(os.path.join(folder, name), 'wb') as f: + f.write(name.encode()) + with self.assertRaisesRegex(M.Refused, r"bad path 'org/example/engine/1\.0\+b1/engine-1\.0\+b1\.jar'"): + self.publish(coords=['org.example:engine:1.0+b1']) + self.assertEqual(self.uploaded, []) + self.assertEqual(self.read_lock()['files'], []) + M.read_lock(self.lock_file) # the lock is still readable: every other stage stays green + + def test_the_real_runner_never_runs_in_a_test(self): + self.assertRaisesRegex(M.Refused, 'test', M.run_gh, ['gh', 'release', 'list']) + + +class Issue1324KilledDownloads(Base): + """Source issue #1324: a killed download leaves .part-* files nothing removes; a cache file another runner removes + between the check and the copy turns the job red.""" + def test_a_stale_part_file_is_removed_and_a_fresh_one_kept(self): + self.lock(self.rows()) + os.makedirs(self.cache) + old, new = os.path.join(self.cache, '.part-old'), os.path.join(self.cache, '.part-new') + for p in (old, new): + open(p, 'w').close() + os.utime(old, (time.time() - 7200, time.time() - 7200)) + self.stage(self.remote()) + self.assertFalse(os.path.exists(old)) + self.assertTrue(os.path.exists(new)) # another runner may be writing it right now + + def test_a_cache_file_removed_right_after_its_check_is_still_staged(self): + self.lock(self.rows()) + self.stage(self.remote()) + shutil.rmtree(self.out) + real = M.sha256 + + def check_then_another_runner_removes_it(data): + for name in os.listdir(self.cache): + os.remove(os.path.join(self.cache, name)) + return real(data) + M.sha256 = check_then_another_runner_removes_it + try: + self.stage(self.remote()) + finally: + M.sha256 = real + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + + def test_a_stage_that_fails_half_way_leaves_no_out_and_the_rerun_is_green(self): + self.lock(self.rows()) + gh = self.remote() + real = gh.download + gh.download = lambda a: real(a) if a['name'].endswith('.jar') else (_ for _ in ()).throw(KeyboardInterrupt()) + with self.assertRaises(KeyboardInterrupt): + self.stage(gh) + self.assertFalse(os.path.exists(self.out)) + self.assertEqual([n for n in os.listdir(self.root) if '.part-' in n], []) # the half-built folder went too + self.stage(self.remote()) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + + +class Issue1330CachePrune(Base): + """Source issue #1330: the runner cache is never pruned.""" + def test_unused_old_entries_go_used_ones_stay_and_unknown_files_are_named(self): + self.lock(self.rows()) + os.makedirs(self.cache) + long_ago, recent = time.time() - 40 * DAY, time.time() - DAY + files = {sha(b'old engine'): long_ago, sha(b'branch engine'): recent, sha(JAR): long_ago, 'notes.txt': long_ago} + for name, t in files.items(): + with open(os.path.join(self.cache, name), 'wb') as f: + f.write(JAR if name == sha(JAR) else b'x') + os.utime(os.path.join(self.cache, name), (t, t)) + gh = self.remote() + self.stage(gh) + self.assertNotIn(f'{sha(JAR)}-engine-1.0.jar', gh.downloads) # 40 days old but used: kept, not fetched again + left = set(os.listdir(self.cache)) + self.assertNotIn(sha(b'old engine'), left) # no lock row, unused for 40 days + self.assertIn(sha(b'branch engine'), left) # another branch's lock may still use it + self.assertIn('notes.txt', left) # never removes a file it did not write + self.assertGreater(os.stat(os.path.join(self.cache, sha(JAR))).st_mtime, recent) # used: its age restarts + self.assertTrue(any('notes.txt' in line for line in self.lines)) + + +class Issue1326ReleaseGrowth(PublishBase): + """Source issue #1326: the lock and the release only grow, and a release holds at most 1000 assets.""" + def test_a_publish_past_the_asset_limit_is_refused_before_any_upload(self): + self.lock([]) + self.release = {f'{i:064x}-old.jar': b'x' for i in range(999)} + with self.assertRaisesRegex(M.Refused, r'release jars has 999 assets.*1000.*new tag'): + self.publish() + self.assertEqual(self.uploaded, []) + self.assertEqual(self.read_lock()['files'], []) + + def test_rows_keep_their_release_when_the_lock_moves_to_a_new_one(self): + self.lock([]) + self.publish() + self.assertEqual({r['release'] for r in self.read_lock()['files']}, {'jars'}) + lock = self.read_lock() + lock['release'] = 'jars-2' # the rollover: new uploads go to a new release, old rows still stage from theirs + with open(self.lock_file, 'w') as f: + json.dump(lock, f) + gh = FakeGitHub(self.release) + gh.releases['jars-2'] = {} + self.stage(gh) + self.assertEqual(gh.listed, ['jars']) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + + def test_prune_lock_keeps_only_the_named_versions(self): + other = 'org/example/engine/0.9' + self.lock(self.rows() + [{'path': f'{other}/engine-0.9.jar', 'sha256': sha(b'0.9')}]) + M.prune_lock(self.lock_file, ['org.example:engine:1.0'], say=self.lines.append) + self.assertEqual(self.paths(), [(r['path'], r['sha256']) for r in self.rows()]) + M.prune_lock(self.lock_file, ['org.example:engine:1.0'], say=self.lines.append) # a rerun changes nothing + self.assertEqual(len(self.read_lock()['files']), 2) + with self.assertRaisesRegex(M.Refused, 'org.example:engine:3.0.*no rows'): # a typo never empties the lock + M.prune_lock(self.lock_file, ['org.example:engine:1.0', 'org.example:engine:3.0'], say=self.lines.append) + self.assertEqual(len(self.read_lock()['files']), 2) + + +class Issue1329ReleaseMistakes(PublishBase): + """Source issue #1329: nothing notices a deleted asset before a build needs it, no way to put it back, no + troubleshooting rows.""" + def test_check_names_every_missing_or_unfinished_asset_without_downloading(self): + self.lock(self.rows()) + gh = self.remote() + del gh.files[f'{sha(POM)}-engine-1.0.pom'] + real = gh.assets + gh.assets = lambda tag: {n: dict(a, state='starter') for n, a in real(tag).items()} + with self.assertRaises(M.Refused) as e: + M.check(self.lock_file, gh, say=self.lines.append) + self.assertIn('engine-1.0.pom: the release jars has no asset', str(e.exception)) + self.assertIn('engine-1.0.jar: the upload of', str(e.exception)) + self.assertEqual(gh.downloads, []) + M.check(self.lock_file, self.remote(), say=self.lines.append) + + def test_publish_puts_back_a_deleted_asset_of_a_locked_version(self): + self.lock([]) + self.publish() + del self.release[f'{sha(JAR)}-engine-1.0.jar'] + self.uploaded.clear() + self.publish() + self.assertEqual(self.uploaded, [f'{sha(JAR)}-engine-1.0.jar']) + self.stage(FakeGitHub(self.release)) + + def test_an_unfinished_upload_is_replaced(self): + self.lock([]) + self.release = {f'{sha(JAR)}-engine-1.0.jar': b'cut'} + self.states = {f'{sha(JAR)}-engine-1.0.jar': 'starter'} + self.publish() + self.assertEqual(self.release[f'{sha(JAR)}-engine-1.0.jar'], JAR) + + def test_every_error_stage_and_check_print_has_a_troubleshooting_row(self): + with open(os.path.join(HERE, 'README.md')) as f: + readme = f.read() + rows = '\n'.join(line for line in readme.split('## Troubleshooting', 1)[1].splitlines() if line.startswith('|')) + for message in ('has no asset', 'the upload of', 'the asset has digest', 'the download has sha256', + 'GitHub answered 404', 'is not empty', 'cannot be read', 'asset limit', 'never reused', + 'no "release" of their own'): + self.assertIn(message, rows) + + +class Issue1336ActionRunsItsOwnScript(unittest.TestCase): + """Source issue #1336: a caller ran the branch's copy of the script. The action runs the copy at its own pinned + path, never one from the caller's workspace, and removes OUT only inside RUNNER_TEMP.""" + def test_the_action_runs_the_script_next_to_it(self): + with open(os.path.join(HERE, 'action.yml')) as f: + action = f.read() + calls = re.findall(r'python3 -I (\S+gradle_cache\.py"?)', action) + self.assertEqual(calls, ['"$ACTION_PATH/gradle_cache.py"'] * len(calls)) + self.assertTrue(calls) + self.assertIn('ACTION_PATH: ${{ github.action_path }}', action) + self.assertNotIn('${{ inputs.', action.split('run: |', 1)[1]) # inputs reach the shell only as env + + def test_the_action_removes_out_only_inside_runner_temp(self): + with open(os.path.join(HERE, 'action.yml')) as f: + script = f.read().split('run: |', 1)[1] + script = textwrap.dedent(script) + tmp = tempfile.mkdtemp(prefix='gradle-cache-action-') + self.addCleanup(shutil.rmtree, tmp, True) + bin_dir = os.path.join(tmp, 'bin') + os.makedirs(bin_dir) + with open(os.path.join(bin_dir, 'python3'), 'w') as f: # stands in for the script: records, then stages + f.write('#!/bin/sh\necho "$@" > "$RUNNER_TEMP/args"; for a; do last=$a; done\n') + os.chmod(os.path.join(bin_dir, 'python3'), 0o755) + for out, ok in (('', True), ('gradle-cache-b', True), ('../outside', False), ('/', False), ('.', False)): + runner_temp = os.path.join(tmp, 'rt') + os.makedirs(runner_temp, exist_ok=True) + env = dict(os.environ, PATH=bin_dir + ':' + os.environ['PATH'], RUNNER_TEMP=runner_temp, + GITHUB_OUTPUT=os.path.join(tmp, 'output'), ACTION_PATH=HERE, LOCK='l.json', OUT=out, + CACHE_DIR='', MAX_AGE='30', REPO='example/repo', GH_TOKEN='') + if out and not out.startswith('/'): + env['OUT'] = os.path.join(runner_temp, out) + r = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True, cwd=tmp) + with self.subTest(out=out): + self.assertEqual(r.returncode == 0, ok, r.stdout + r.stderr) + self.assertTrue(os.path.isdir(tmp)) + + +class LensCacheDir(Base): + """Lens S2 (major): cache-dir ~/.cache/x reached the script unexpanded, so the cache was a `~` folder in the checkout, + wiped by every checkout. `~` is the home folder; a relative path is refused.""" + def main(self, cache): + """main() as the action calls it, with HOME and the workspace (the cwd) both inside self.root.""" + top = tempfile.mkdtemp(dir=self.root) + home, ws = os.path.join(top, 'home'), os.path.join(top, 'ws') + os.makedirs(home), os.makedirs(ws) + err, cwd = io.StringIO(), os.getcwd() + os.chdir(ws) + try: + with mock.patch.dict(os.environ, {'HOME': home}), \ + mock.patch.object(M, 'GitHub', lambda token, repo: self.remote()), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err): + code = M.main(['stage', '--lock', self.lock_file, '--out', self.out, '--cache', cache, + '--repo', 'example/repo']) + finally: + os.chdir(cwd) + return code, err.getvalue(), home, ws + + def test_a_tilde_cache_dir_is_the_home_folder_never_a_folder_in_the_checkout(self): + self.lock(self.rows()) + code, err, home, ws = self.main('~/.cache/my-ci-m2') + self.assertEqual(code, 0, err) + self.assertEqual(sorted(os.listdir(os.path.join(home, '.cache', 'my-ci-m2'))), sorted([sha(JAR), sha(POM)])) + self.assertEqual(os.listdir(ws), []) + + def test_a_relative_or_unexpanded_cache_dir_is_refused(self): + self.lock(self.rows()) + for cache in ('cache', '~no-such-user-x/c', ''): + with self.subTest(cache=cache): + code, err, _, ws = self.main(cache) + self.assertEqual(code, 1) + self.assertIn('cache dir', err) + self.assertEqual(os.listdir(ws), []) + self.assertFalse(os.path.exists(self.out)) + + +class LensPublishAfterRollover(PublishBase): + """Lens S1+S2 (major): after the lock's "release" moved to a new tag, publish of a locked version (the repair of a + deleted asset, or a plain rerun) raised KeyError: the new tag was never listed.""" + def move_to(self, tag): + lock = self.read_lock() + lock['release'] = tag + with open(self.lock_file, 'w') as f: + json.dump(lock, f) + + def test_a_deleted_asset_is_put_back_after_the_move(self): + self.lock([]) + self.publish() + self.move_to('jars-2') + del self.release[f'{sha(JAR)}-engine-1.0.jar'] + self.uploaded.clear() + self.publish() + self.assertEqual(self.uploaded, [f'{sha(JAR)}-engine-1.0.jar']) # back into the row's own release + self.assertEqual(self.created, []) # no empty jars-2 for it + self.assertEqual({r['release'] for r in self.read_lock()['files']}, {'jars'}) + self.stage(FakeGitHub(self.release)) + + def test_a_rerun_after_the_move_changes_nothing_and_a_new_version_goes_to_the_new_release(self): + self.lock([]) + self.publish() + self.move_to('jars-2') + self.uploaded.clear() + self.publish() + self.assertEqual((self.uploaded, self.created), ([], [])) + folder = os.path.join(self.m2, 'org', 'example', 'engine', '1.1') + os.makedirs(folder) + for name in ('engine-1.1.jar', 'engine-1.1.pom'): + with open(os.path.join(folder, name), 'wb') as f: + f.write(name.encode()) + self.publish(coords=['org.example:engine:1.0', 'org.example:engine:1.1']) + self.assertEqual(self.created, ['jars-2']) + self.assertEqual(sorted(self.uploaded), + sorted(f'jars-2/{sha(n.encode())}-{n}' for n in ('engine-1.1.jar', 'engine-1.1.pom'))) + self.assertEqual({r['path'].split('/')[3]: r['release'] for r in self.read_lock()['files']}, + {'1.0': 'jars', '1.1': 'jars-2'}) + + +class LensSameVersionTwice(PublishBase): + """Lens S1+S2 (minor): one version named twice wrote every row twice, and the lock then refused itself.""" + def test_one_version_named_twice_or_spelled_two_ways_writes_each_row_once(self): + for coords in (['org.example:engine:1.0'] * 2, ['org.example:engine:1.0', 'org/example:engine:1.0']): + with self.subTest(coords=coords): + self.lock([]) + self.release.clear() + self.publish(coords=coords) + self.assertEqual(self.paths(), [(r['path'], r['sha256']) for r in self.rows()]) + self.lock([]) # a rerun after a crash before the lock write: the assets are already there + self.publish(coords=coords) + M.read_lock(self.lock_file) + self.assertEqual(self.paths(), [(r['path'], r['sha256']) for r in self.rows()]) + + +class LensLegacyLockRollover(PublishBase): + """Lens S2 round 2 (minor): a lock whose rows name no release (the source format) and the rollover done as the README + said (move "release", publish the new version): publish wrote the new tag into every old row, whose assets are in the + old release, and the repair then re-uploaded the old versions into the new release.""" + def setUp(self): + super().setUp() + self.lock(self.rows()) # no "release" in any row + self.release = {f'{sha(JAR)}-engine-1.0.jar': JAR, f'{sha(POM)}-engine-1.0.pom': POM} + folder = os.path.join(self.m2, 'org', 'example', 'engine', '1.1') + os.makedirs(folder) + for name in ('engine-1.1.jar', 'engine-1.1.pom'): + with open(os.path.join(folder, name), 'wb') as f: + f.write(name.encode()) + + def move_to(self, tag): + lock = self.read_lock() + lock['release'] = tag + with open(self.lock_file, 'w') as f: + json.dump(lock, f) + + def lock_bytes(self): + with open(self.lock_file, 'rb') as f: + return f.read() + + def test_a_move_before_every_row_names_its_release_is_refused_before_any_upload(self): + self.move_to('jars-2') + before = self.lock_bytes() + for coords in (['org.example:engine:1.1'], ['org.example:engine:1.0'], ['org.example:engine:1.0'] * 2): + with self.subTest(coords=coords): # the new version, the repair of an old one, and a rerun of it + with self.assertRaisesRegex(M.Refused, r'(?s)no "release" of their own.*engine-1\.0\.jar.*' + r'engine-1\.0\.pom.*Nothing was uploaded'): + self.publish(coords=coords) + self.assertEqual((self.uploaded, self.created), ([], [])) + self.assertEqual(self.lock_bytes(), before) + self.assertNotIn('jars-2', self.others) + self.move_to('jars') # undone by hand: stage still finds every old file + gh = FakeGitHub(self.release) + self.stage(gh) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + + def test_the_readme_rollover_writes_every_rows_release_first_and_a_rerun_changes_nothing(self): + self.publish() # step 1: publish a version the lock already has; it uploads nothing, only writes the releases + self.assertEqual((self.uploaded, self.created), ([], [])) + self.assertEqual({r['release'] for r in self.read_lock()['files']}, {'jars'}) + self.move_to('jars-2') # step 2 + self.publish(coords=['org.example:engine:1.1']) # step 3 + self.assertEqual(self.created, ['jars-2']) + self.assertEqual(sorted(self.uploaded), + sorted(f'jars-2/{sha(n.encode())}-{n}' for n in ('engine-1.1.jar', 'engine-1.1.pom'))) + self.assertEqual({r['path'].split('/')[3]: r['release'] for r in self.read_lock()['files']}, + {'1.0': 'jars', '1.1': 'jars-2'}) + gh = FakeGitHub(self.release) + gh.releases['jars-2'] = dict(self.others['jars-2']) + self.stage(gh) + self.assertEqual(len(self.staged()), 4) + before, self.uploaded = self.lock_bytes(), [] + self.publish(coords=['org.example:engine:1.0', 'org.example:engine:1.1']) + self.assertEqual((self.uploaded, self.created, self.lock_bytes()), ([], ['jars-2'], before)) + + def test_a_deleted_asset_of_a_row_that_names_no_release_is_never_guessed(self): + """The lock's release may have moved, so publish cannot tell 'deleted here' from 'lives in an older release'.""" + del self.release[f'{sha(JAR)}-engine-1.0.jar'] + before = self.lock_bytes() + with self.assertRaisesRegex(M.Refused, r'no "release" of their own.*engine-1\.0\.jar'): + self.publish() + self.assertEqual((self.uploaded, self.lock_bytes()), ([], before)) + lock = self.read_lock() # the README's repair: write the tag that held it, then publish + for row in lock['files']: + row['release'] = 'jars' + with open(self.lock_file, 'w') as f: + json.dump(lock, f) + self.publish() + self.assertEqual(self.uploaded, [f'{sha(JAR)}-engine-1.0.jar']) + self.stage(FakeGitHub(self.release)) + + +class LensUnwritableCache(Base): + """Lens S1 (minor): a read-only, foreign or full cache dir turned stage red, though the cache only saves downloads.""" + def test_a_cache_dir_that_cannot_be_made_only_warns(self): + self.lock(self.rows()) + open(os.path.join(self.root, 'a-file'), 'w').close() + self.cache = os.path.join(self.root, 'a-file', 'cache') + self.stage(self.remote()) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + self.assertTrue(any(line.startswith('WARN:') and 'cache' in line for line in self.lines), self.lines) + + def test_a_full_disk_on_a_cache_write_only_warns(self): + self.lock(self.rows()) + + def full(*_): + raise OSError(errno.ENOSPC, 'No space left on device') + with mock.patch.object(M, 'write_atomic', full): + self.stage(self.remote()) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + self.assertTrue(any(line.startswith('WARN:') and 'No space' in line for line in self.lines), self.lines) + + def test_a_cache_file_that_cannot_be_read_only_warns(self): + self.lock(self.rows()) + + def unreadable(*_): + raise PermissionError(errno.EACCES, 'Permission denied') + gh = self.remote() + with mock.patch.object(M, 'cached', unreadable): + self.stage(gh) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + self.assertEqual(len(gh.downloads), 2) + self.assertTrue(any(line.startswith('WARN:') and 'cannot be read' in line for line in self.lines), self.lines) + + def test_a_cache_owned_by_another_user_only_warns(self): + if os.geteuid() == 0: + self.skipTest('root ignores file modes; the two tests above cover a cache that cannot be written') + self.lock(self.rows()) + self.stage(self.remote()) + shutil.rmtree(self.out) + for name in os.listdir(self.cache): + os.chmod(os.path.join(self.cache, name), 0) + os.chmod(self.cache, 0o555) + self.addCleanup(os.chmod, self.cache, 0o755) + gh = self.remote() + self.stage(gh) + self.assertEqual(self.staged(), [f'{V}/engine-1.0.jar', f'{V}/engine-1.0.pom']) + self.assertEqual(len(gh.downloads), 2) + self.assertTrue(any(line.startswith('WARN:') for line in self.lines), self.lines) + + +class LensActionOut(unittest.TestCase): + """Lens S1+S2: the action's `out` guard and its `rm -rf`. Only a gradle-cache* folder right under RUNNER_TEMP (or + below one) is removed, never RUNNER_TEMP, the runner's own _* folders, another tool's folder or a sibling.""" + def setUp(self): + with open(os.path.join(HERE, 'action.yml')) as f: + self.script = textwrap.dedent(f.read().split('run: |', 1)[1]) + self.tmp = tempfile.mkdtemp(prefix='gradle-cache-action-') + self.addCleanup(shutil.rmtree, self.tmp, True) + self.rt, bin_dir = os.path.join(self.tmp, 'rt'), os.path.join(self.tmp, 'bin') + os.makedirs(bin_dir), os.makedirs(self.rt) + with open(os.path.join(bin_dir, 'python3'), 'w') as f: # stands in for the script: notes whether out exists + f.write('#!/bin/sh\nout=; prev=\nfor a; do [ "$prev" = --out ] && out=$a; prev=$a; done\n' + 'if [ -e "$out" ]; then echo present; else echo gone; fi > "$RUNNER_TEMP/../out-state"\n') + os.chmod(os.path.join(bin_dir, 'python3'), 0o755) + self.path = bin_dir + ':' + os.environ['PATH'] + + def run_action(self, out): + env = dict(os.environ, PATH=self.path, RUNNER_TEMP=self.rt, GITHUB_OUTPUT=os.path.join(self.tmp, 'output'), + ACTION_PATH=HERE, LOCK='l.json', OUT=out, CACHE_DIR='', MAX_AGE='30', REPO='example/repo', GH_TOKEN='') + return subprocess.run(['bash', '-c', self.script], env=env, capture_output=True, text=True, cwd=self.tmp) + + def test_runner_folders_other_tools_and_siblings_are_refused_and_kept(self): + rt = self.rt + for out in (rt, f'{rt}/_runner_file_commands', f'{rt}/_github_home', f'{rt}/setup-java-stuff', + f'{rt}/gradle-cache-m2/../_runner_file_commands', f'{self.tmp}/rt2/gradle-cache-m2', + f'{self.tmp}/rtgradle-cache-m2', f'{rt}/gradle-cachex/../x'): + victim = os.path.join(os.path.realpath(out) if out != rt else rt, 'victim') + os.makedirs(os.path.dirname(victim), exist_ok=True) + open(victim, 'w').close() + r = self.run_action(out) + with self.subTest(out=os.path.relpath(out, self.tmp)): + self.assertNotEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn('must be a folder', r.stdout + r.stderr) + self.assertTrue(os.path.exists(victim)) + + def test_the_action_empties_out_before_the_script_runs(self): + for out in ('', f'{self.rt}/gradle-cache-m2', f'{self.rt}/gradle-cache-two/m2'): + stale = os.path.join(out or f'{self.rt}/gradle-cache-m2', 'org', 'old.jar') + os.makedirs(os.path.dirname(stale), exist_ok=True) + open(stale, 'w').close() + r = self.run_action(out) + with self.subTest(out=out): + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + with open(os.path.join(self.tmp, 'out-state')) as f: + self.assertEqual(f.read().strip(), 'gone') + + +class GitHubClient(unittest.TestCase): + """The token goes to GitHub only, never on to the redirect (a signed storage URL).""" + def test_the_token_is_never_sent_on_a_redirect(self): + seen = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + seen.append((self.path, self.headers.get('Authorization'))) + if self.path == '/asset': + self.send_response(302) + self.send_header('Location', f'http://127.0.0.1:{server.server_port}/signed') + self.send_header('Content-Length', '0') + self.end_headers() + else: + self.send_response(200) + self.send_header('Content-Length', '5') + self.end_headers() + self.wfile.write(b'bytes') + + def log_message(self, *_): + pass + server = http.server.HTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + url = f'http://127.0.0.1:{server.server_port}/asset' + with mock.patch.dict(os.environ, {'no_proxy': '*', 'NO_PROXY': '*'}): + data = M.GitHub('test-token', 'example/repo').download({'url': url}) + self.assertEqual(data, b'bytes') + self.assertEqual(seen, [('/asset', 'Bearer test-token'), ('/signed', None)]) + + +if __name__ == '__main__': + unittest.main() From b744d05f897aad4dec1e85f86f4e7ced82cd0cc9 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:11:15 +0100 Subject: [PATCH 14/27] fork: the fork-follow test's fake gh no longer reads as a real workflow disable call (#23) The fake gh routed the disable call with one literal "PUT .../actions/workflows//disable" pattern. Local command guards that refuse real disable calls read that text and refuse to run the whole test. The route now matches any PUT under actions/workflows and accepts only a last path segment "disable"; anything else still has no route. The test records and checks the same calls as before. Co-authored-by: Claude Opus 5.5 --- .github/scripts/fork-follow.test.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 55bb56bb68794..828e9be2e231f 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -50,7 +50,12 @@ done case "$method $path" in "GET repos/me/t3code") body="{\"has_issues\":$(cat "$api/has_issues"),\"parent\":{\"full_name\":\"up/t3code\"}}" ;; "GET repos/me/t3code/actions/workflows") body="$(cat "$api/workflows.json")" ;; - "PUT repos/me/t3code/actions/workflows/"*/disable) echo "disable $path" >> "$api/writes"; exit 0 ;; + # The workflow disable route, matched on its last path segment: written as one literal + # .../workflows//disable pattern, command guards that refuse real disable calls refuse to + # run this test. Any other PUT under actions/workflows has no route, as before. + "PUT repos/me/t3code/actions/workflows/"*) + [[ "${path##*/}" == disable ]] || { echo "fake gh: no route for $method $path" >&2; exit 1; } + echo "disable $path" >> "$api/writes"; exit 0 ;; "GET repos/me/t3code/actions/runs?"*) query="${path#*\?}" sha="" st="" [[ "&$query" != *"&head_sha="* ]] || { sha="${query#*head_sha=}"; sha="${sha%%&*}"; } From 75cd6b50a654172ac629757c342ea57e3f417d7d Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:39:47 +0100 Subject: [PATCH 15/27] fork: Fork tests on the repository's own fleet runners, chosen per run (CI_RUNNERS) (#13) * fork: Fork tests on the repository's own fleet runners, chosen per run (CI_RUNNERS) A GitHub-hosted `route` job picks, for each Linux job of Fork tests, an idle fleet runner or GitHub-hosted Ubuntu 24.04. The repository variable CI_RUNNERS sets the mode: auto (also unset), fleet or github. Only trusted runs may use the fleet: the owner's pushes to main and the owner's own same-repository pull requests. Pull requests from other repositories never do. Each fleet runner's job-started hook re-checks the same rule (`gate`). Each routed job waits for memory on a fleet runner before it starts work. A stateless sweep (fork-fleet.yml, every 10 minutes) cancels and re-runs, on GitHub-hosted runners, the runs that the fleet did not take or did not finish. All facts live in .github/fork-fleet.json. The runbook, owner steps and failure table are in .github/fork-fleet.md. No upstream file changes. Co-Authored-By: Claude Opus 5.5 * fork fleet: one-job runners made by a root dispatcher for trusted runs only; the hook kills a refused job Fix round r1 for the #13 security lenses. - Blocker (s1): no standing self-hosted runner. A root dispatcher on each fleet host (fork-fleet.py dispatch + fork-fleet-launch.sh) starts a one-job JIT runner only for a queued job that asks for exactly a pool's label plus the per-run random label route made for it, of a run whose GitHub record passes the trust rule, when no other queued job could take that runner. The runner runs from a fresh copy of a root-owned template as a user of its own, its environment (JIT config, run, attempt, hook) from a root-only file. The job-started hook (root-owned) is the second line: it kills Runner.Worker, so no later step runs, if: always() ones included. - s2 minor 1: runs-on follows CI_RUNNERS on a re-run; the sweep reads the latest attempt; the watch watches its own attempt. - s2 minor 2: route is a step of changes, not a job. - s2 minor 3: a truncated or garbled reply is never red (IncompleteRead, ValueError and the like are caught). - s1 minors: the sweep applies the dispatcher's trust rule; fleet_watch bounds the wait per run (10 min auto, 2 h fleet) and the sweep cancels a fleet-mode run past 2 h with an error; a test for "a person's cancel stands". - Doc: rewritten runbook (.github/fork-fleet.md) with host setup, owner steps, a real-runner check and the failure table; skip-only shards go hosted. Co-Authored-By: Claude Opus 5.5 * fork fleet: one user per slot, attempt in the label, per-pool locks; upstream sync GitHub-hosted only - Each slot runs as a runner user of its own (RUNNER_USERS). HOME and TMPDIR live in the job's copy (0700 to that user). Before a user is reused, all of its processes are killed and its /dev/shm files and old copies are removed. - Each routed runs-on adds -a to the run's label, so a re-run's job can never land on an idle runner made for an earlier attempt. - One dispatcher lock per pool; a host failure skips only that pool, with a free 0 heartbeat that says why. - The launcher refuses a runner template older than the newest actions/runner release (fail closed); the heartbeat tells the owner to refresh it. - Queued, not yet started jobs count against free slots (tested). - Runbook host steps 1-2 are safe to run twice; the launcher checks that TEMPLATE and WORK_ROOT share a filesystem. - Owner decision: upstream sync on GitHub-hosted runners only, enforced by a test over fork-follow.yml, fork-checks.yml and the sweep. Co-Authored-By: Claude Opus 5.5 * fork fleet: format the runbook (oxfmt), the Lint job's only finding Co-Authored-By: Claude Opus 5.5 * fork fleet: each runner a network of its own, private temp file systems; the upstream sync's push runs GitHub-hosted - Network: each runner user gets its own network namespace with its own loopback and one link out through the host to the public internet only. The nft table inet fork_fleet refuses the host itself, RFC 1918, CGNAT, link-local, the other reserved ranges and all of IPv6; the unit's IPAddressDeny refuses the same ranges again in the kernel. The launcher (re)makes both at every start and starts no runner without them. New verb check-net proves it from inside a runner's own sandbox (host step 6). - Temp file systems: each unit gets its own /tmp, /var/tmp, /dev/shm and /run, its own IPC, no /home and no other user's processes. Root's recursive delete in /dev/shm is gone: a runner user could swap a directory there for a symlink and make root delete any file. - Upstream sync: fork-follow-push.sh tags fork-follow/synced/ before it moves main, and moves main only when the tag push worked. The trust rule reads the tag anonymously (git ls-remote --exit-code): a synced commit, or any doubt, is untrusted, so the sync's push run is GitHub-hosted in every mode. - A pool its dispatcher closed or reports down is a ::notice:: on the run page. Tests for the own-group check and the launch lock. - Runbook: trust rule, containment and network sections; host steps 1b (network prerequisites) and 6 (check-net); steps 2 and 3 stop at the first failure and replace nothing before the new files are checked. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 1 + .github/fork-fleet.json | 60 + .github/fork-fleet.md | 636 +++++++ .github/scripts/fork-fleet-hook.sh | 5 + .github/scripts/fork-fleet-launch.sh | 450 +++++ .github/scripts/fork-fleet.py | 973 +++++++++++ .github/scripts/fork-fleet.test.py | 2383 ++++++++++++++++++++++++++ .github/scripts/fork-follow-push.sh | 16 +- .github/scripts/fork-follow.test.sh | 14 + .github/workflows/fork-fleet.yml | 55 + .github/workflows/fork-release.yml | 7 + .github/workflows/fork-tests.yml | 164 +- 12 files changed, 4732 insertions(+), 32 deletions(-) create mode 100644 .github/fork-fleet.json create mode 100644 .github/fork-fleet.md create mode 100755 .github/scripts/fork-fleet-hook.sh create mode 100755 .github/scripts/fork-fleet-launch.sh create mode 100644 .github/scripts/fork-fleet.py create mode 100644 .github/scripts/fork-fleet.test.py create mode 100644 .github/workflows/fork-fleet.yml diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 4be181a18e16a..56e607f6ed3c0 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -7,6 +7,7 @@ # is a note; one that was present and then deleted fails. .github/scripts/fork-ci-tests.test.cjs .github/scripts/fork-contracts.test.py +.github/scripts/fork-fleet.test.py .github/scripts/fork-follow.test.sh .github/scripts/fork-release-workflow.test.py .github/scripts/fork-release.test.sh diff --git a/.github/fork-fleet.json b/.github/fork-fleet.json new file mode 100644 index 0000000000000..af0b2cb0a1265 --- /dev/null +++ b/.github/fork-fleet.json @@ -0,0 +1,60 @@ +{ + "_doc": [ + "Where fork-tests.yml's Linux jobs run: this repository's own fleet of self-hosted runners, or GitHub-hosted.", + "The one place for every fact about this repository and its fleet: .github/scripts/fork-fleet.py has none.", + "Read by `route` (a step of fork-tests.yml's changes job), `watch` (fork-tests.yml), `sweep` (fork-fleet.yml),", + "and on each fleet host by `dispatch` and `gate` (an installed copy of main's script and of this file).", + "The setting is the repository variable named in `variable`: auto (unset too), fleet or github.", + "Runbook, owner steps and the failure table: .github/fork-fleet.md." + ], + "repository": "scuttletoby/t3code", + "variable": "CI_RUNNERS", + "hosted": "ubuntu-24.04", + "trusted": { + "workflows": [".github/workflows/fork-tests.yml"], + "actors": ["scuttletoby"], + "push_branches": ["main"] + }, + "upstream_sync": { + "_doc": [ + "Upstream's code runs on GitHub-hosted runners only (owner decision). The daily sync pushes main with the owner's", + "token, so its push run would pass `trusted`: scripts/fork-follow-push.sh tags each commit it moves main to as", + " before the move, and a push run of a tagged commit never uses the fleet. Keep the tags: a", + "run can be re-run for 30 days." + ], + "tag_prefix": "fork-follow/synced/" + }, + "pools": [ + { + "_doc": "The owner's PC: its dispatcher runs on the always-on host and starts runners on the PC over ssh.", + "name": "pc", + "label": "fleet-pc-t3code", + "slots": 1, + "beacon": "FLEET_BEACON_PC", + "launch": ["/opt/fork-fleet/launch-pc"], + "waits": false + }, + { + "_doc": "The always-on host (the owner's workspace, not a VM of its own): each runner runs as its own user in its own network namespace with internet only (fork-fleet.md, Containment). Fleet mode queues here.", + "name": "vm", + "label": "fleet-vm-t3code", + "slots": 2, + "beacon": "FLEET_BEACON_VM", + "launch": ["/opt/fork-fleet/fork-fleet-launch.sh"], + "waits": true + } + ], + "beacon_fresh_seconds": 180, + "stuck_seconds": 600, + "fleet_stuck_seconds": 7200, + "dispatch": { "every_seconds": 20, "beacon_seconds": 60, "idle_passes": 3 }, + "jobs": [ + { "job": "test", "shards": 2, "gates": ["test"], "memory_mb": 4096 }, + { "job": "test_web", "shards": 2, "gates": ["test_web"], "memory_mb": 4096 }, + { "job": "build", "gates": ["build", "typecheck"], "memory_mb": 4096 }, + { "job": "lint", "gates": [], "memory_mb": 3072 }, + { "job": "rust", "gates": ["rust"], "memory_mb": 2048 }, + { "job": "test_server", "shards": 8, "gates": ["test_server"], "memory_mb": 3072 }, + { "job": "release_smoke", "gates": ["release_smoke"], "memory_mb": 2048 } + ] +} diff --git a/.github/fork-fleet.md b/.github/fork-fleet.md new file mode 100644 index 0000000000000..ad773d3883f35 --- /dev/null +++ b/.github/fork-fleet.md @@ -0,0 +1,636 @@ +# Fork fleet runners + +Fork only. Fork tests (`.github/workflows/fork-tests.yml`) can run its Linux jobs +on this repository's own machines (the fleet) instead of GitHub-hosted +Ubuntu 24.04. The macOS job and every other fork workflow stay GitHub-hosted. + +No fleet runner waits for work. A dispatcher on each fleet host starts a +one-job runner for one queued job of a trusted run, and that runner is gone +after the job. A job that names a fleet label by itself finds no runner. + +| File | What it does | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.github/fork-fleet.json` | Every fact about the repository and its fleet: the setting's name, the trust rule (workflows, actors, branches), the pools (label, slots, heartbeat variable, launch command, which pool `fleet` mode queues on), the jobs (shards, change gates, memory), and the time limits. | +| `.github/scripts/fork-fleet.py` | `route`, `watch`, `sweep`, `dispatch` and `gate`. Standard library only. It holds no repository facts. | +| `.github/scripts/fork-fleet-launch.sh` | Runs on a fleet host, as root, for `dispatch`: `start`, `ready`, `active`, `fits`, `stop` and `prune` of one-job runners. | +| `.github/scripts/fork-fleet-hook.sh` | Each fleet runner's job-started hook. It runs `gate` from the installed copy. | +| `.github/scripts/fork-fleet.test.py` | The tests, and checks that the workflows match the config. Run by the `workflows` job. | +| `.github/workflows/fork-tests.yml` | The `Route` step of `changes` feeds each Linux job's `runs-on`. The `fleet_watch` job bounds the wait for a fleet runner. | +| `.github/workflows/fork-fleet.yml` | The sweep, every 10 minutes (GitHub-hosted). | +| `.github/workflows/fork-release.yml` | Grants `actions: write` to the called Fork tests, because `fleet_watch` asks for it. A release run is always GitHub-hosted. | + +No upstream file changes. Upstream's `ci.yml` and `release.yml` are untouched, so +the fork-follow sweep and the drift check behave as before. + +**Owner decision (2026-11-12): upstream sync on GitHub-hosted runners only.** +The upstream sync (`fork-follow.yml`, with `fork-checks.yml` and the sweep +`fork-fleet.yml`) runs only on GitHub-hosted runners, never on fleet or other +self-hosted ones, whatever `CI_RUNNERS` says. This is enforced, not just written +down: `test_the_upstream_sync_runs_on_github_hosted_runners_only` in +`fork-fleet.test.py` fails when any job of those workflows gets a `runs-on` +other than a fixed GitHub-hosted image, or when the release they call could +route. + +## The setting: `CI_RUNNERS` + +A repository variable. If it is unset or empty, the mode is `auto`. + +| Value | Trusted run | Untrusted run | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `auto` | Each job that will run goes to a pool whose dispatcher reports a free slot. Pools are tried in config order (`pc`, then `vm`), jobs in config order (longest first). The other jobs run GitHub-hosted. A pool counts only with a fresh heartbeat (at most 180 s old). No heartbeat, an old one or an unreadable one means GitHub-hosted, with a note. `auto` never turns a run red. | GitHub-hosted | +| `fleet` | Every job that will run goes to the fleet: pools with a free slot first, then the rest queue on the pool marked `waits` (`vm`). A shard whose change gate is off only prints a skip, so it runs GitHub-hosted. A fleet job that waits 2 hours cancels the run with an error. | GitHub-hosted | +| `github` | Everything GitHub-hosted, as before the fleet. Fleet jobs already queued when the setting changed are cancelled and re-run GitHub-hosted by the sweep after 10 minutes. | GitHub-hosted | +| anything else | The `Route` step fails, so `changes` and the run are red. A typo must not quietly mean `auto`. | (same) | + +The route calls no API and reads no secret. In `auto` it reads only the pools' +heartbeat variables (`FLEET_BEACON_PC`, `FLEET_BEACON_VM`), which each +dispatcher writes: `{"at": , "free": }`. + +## The trust rule + +A run is **trusted** when all of these hold: + +- the workflow is `fork-tests.yml` of `scuttletoby/t3code`; +- the actor _and_ the triggering actor are `scuttletoby`; +- the run is a push to `main` of a commit the upstream sync did not make, or a + pull request whose head and base are both this repository and which + `scuttletoby` opened. + +Everything else is untrusted. That includes pull requests from other +repositories, bots, other events, the sweep's own re-runs (triggered by +`github-actions`), and `workflow_call`/`workflow_dispatch` release runs. +The repository is public, so a pull request from another fork would otherwise +run its own code on the owner's machines. Owner rule: pull requests from outside +the fork never run on the fleet. + +**The upstream sync's push runs GitHub-hosted.** Owner decision: upstream's +code runs on GitHub-hosted runners only. The daily sync (`fork-follow.yml`) +moves `main` with `FORK_PUSH_TOKEN`, the owner's own token, so the push run that +follows has the owner as actor and would pass every check above. So +`fork-follow-push.sh advance` first pushes the tag +`fork-follow/synced/` (`upstream_sync.tag_prefix` in `fork-fleet.json`) +for the commit it moves `main` to, and moves `main` only when that push +worked. The rule asks this repository, anonymously +(`git ls-remote --exit-code`), whether the pushed commit has that tag: if it +has, or if the answer cannot be read, the run is untrusted. An upstream commit +cannot fake its way past this: only a holder of push rights here (the owner, +the sync's token) can make a tag in this repository, and nothing in a commit +(its message, author, files) or upstream (its branches, tags, pull requests) +makes or removes one. The only thing a tag does is send a run GitHub-hosted. +Keep the tags: a run can be re-run for 30 days. What this does not cover: once +synced, upstream's code is in `main`, so the owner's later pull requests and +pushes, which build on it, run it on the fleet. + +### Three lines of defence + +1. **`route`** sends an untrusted run GitHub-hosted. This is only a convenience: + a pull request can change the workflow, so nothing relies on it. +2. **The dispatcher** is the root of the design. It runs as root on the fleet + host, from a root-owned copy of `main`'s script and config. It starts a + runner only when all of these hold: + - the queued job asks for exactly two labels: a pool's label and the per-run + label `route` made for that job, with the attempt the job's `runs-on` adds + (`--<16 random hex>-a`); + - GitHub's own record of the run passes the trust rule. The dispatcher reads + the run, not anything a job wrote, and reads each pull request from + `pulls/`. A pull request run from a fork lists no pull request of this + repository, so it is refused; + - the job's attempt is the run's attempt that it read, and the attempt in the + job's label (`-a`, see "Races") is that attempt too; + - no other queued job, of any run, could be given that runner. A runner gets + only those two labels (plus GitHub's defaults, which the check counts too). + + The runner is a one-job (JIT) registration named `-`. + It runs from a fresh copy of a root-owned runner template, in a transient + systemd unit, as the runner user of a free slot (`RUNNER_USERS`, one user per + slot): no other unit runs as that user at the same time. Its environment + comes from a root-only file: the JIT config, the run and attempt it was made + for, `HOME` and `TMPDIR` (both inside the copy) and the hook. + So a job that names `fleet-vm-t3code` by itself, from a changed workflow or a + fork's pull request, stays queued with no runner. No standing self-hosted + runner exists that a label could reach. + +3. **The gate** is the job-started hook. It runs before the job's first step and + before any container setup. It refuses a job if it is not the run and attempt + its runner was made for, or if the job breaks the trust rule. To refuse, it + **kills the job's `Runner.Worker`** (SIGKILL). An exit code alone is not + enough, because the runner treats the hook as a pre-job step and still runs + later `if: always()` and `if: failure()` steps. With the worker killed, the + runner marks the job failed and runs no later step. If the gate itself + crashes, it refuses. + +Two more things limit what a job can do: + +- **#12:** jobs of a pull request from another repository do not run at all. + The fork's approval policy is `all_external_contributors`, so a stranger's + run first waits for the owner's approval. +- **Containment:** each runner uses a single copy, and its `HOME` and `TMPDIR` + are in that copy. The copy's top directory is its runner user's alone (0700), + so the other slot's user cannot read its JIT config, tokens or caches, or + change how it starts. Each unit has its own `/tmp`, `/var/tmp`, `/dev/shm` + and `/run` (memory file systems that go with the unit) and its own IPC. It + sees no `/home`, no other user's processes, and the rest of the system + read-only. The unit's whole process group is killed when it stops, and a unit + lives at most 1 hour. A runner user has no sudo, no docker group, no lingering + and no cron or at. Before a user is given a new job, the launcher kills every + process left of it and removes every copy it ran in (with its home). So a job + leaves nothing for the next job: not a process, not a file in its home, not + one in shared memory. Root never deletes inside a directory a runner user can + still write: a copy is removed only once no unit can write it. The launcher + refuses two slots that are one user, or a runner user in any other group or + lingering. +- **Network:** the `vm` pool runs on VM106, the owner's workspace, not on a VM + of its own. So each runner user has a network namespace of its own + (`fork-fleet-`) with its own loopback: a job reaches none of the host's + loopback services (T3's server, web GUIs, databases) and no abstract socket of + it. Its one link goes out through the host to the public internet only. The + nft table `inet fork_fleet` refuses the host itself, RFC 1918 (the LAN and its + other machines), CGNAT `100.64.0.0/10` (Tailscale and the like), link-local, + the other reserved ranges and all of IPv6. The unit's `IPAddressDeny` refuses the same + ranges again, in the kernel, should the table ever go. The launcher (re)makes + the namespace and the table at every start and starts no runner when either + fails. Host step 6 proves it from inside a runner. + +Fleet jobs read no secrets (a test checks this). The fleet's GitHub token lives +only on the fleet host, readable by root. + +### Why the gate kills the worker (actions/runner, `main`, read 2026-11-12) + +- The hook path is read from the worker's process environment, and the hook is + added to the pre-job steps with condition `always()`. Container setup comes + after it (`src/Runner.Worker/JobExtension.cs` ~303-315). A workflow cannot set + the hook. +- The hook's environment starts empty. It gets the process environment plus the + runtime `GITHUB_*`/`RUNNER_*` values, not the workflow's `env:` + (`src/Runner.Worker/JobHookProvider.cs:74`). So `FORK_FLEET_RUN` and + `FORK_FLEET_ATTEMPT` from the root-only file can be trusted. +- Later steps evaluate their own `if:`. After a failed hook, `always()` and + `failure()` steps still run, which was the s1 blocker. +- When the worker dies, the listener marks the job failed, and nothing of it + runs again (`src/Runner.Listener/JobDispatcher.cs:1179-1219`). +- The runner reads `ACTIONS_RUNNER_INPUT_JITCONFIG` from its environment and then + deletes it (`src/Runner.Listener/CommandSettings.cs:112-138`). The JIT config + never appears in argv. +- The runner loads `.env` from its own directory and lets it override its + environment (`src/Runner.Listener/Program.cs:172-194`). This is why the + launcher refuses a template that has a `.env` (or `.path`, `.runner`, + `.credentials`). Each copy is used for one job only. + +`GateProcess` in `fork-fleet.test.py` models this. A process named +`Runner.Worker` runs the installed hook, then an `always()` step that writes a +marker. A refused job must leave no marker. A mutant gate that only exits 1 +does leave the marker, and that turns the test red. + +## How a run flows + +1. `changes` (upstream's change detector) decides which jobs run. Its last + step, `Route`, writes `runners` (job key to labels or `ubuntu-24.04`), + `mode`, `attempt` and `watch`. +2. Each Linux job's `runs-on` uses that answer only when the setting is not + `github` and either `route` ran in this attempt or the mode was `fleet` and + still is. Otherwise it is `ubuntu-24.04`. So in `auto`, a re-run of failed + jobs goes GitHub-hosted instead of reusing attempt 1's picks. A re-run after + the owner switched to `github` or `auto` follows the new setting. +3. The dispatcher (`dispatch`, every 20 s) lists queued and in-progress runs and + their queued jobs. It cleans up leftovers, then starts at most one runner per + job, as long as the pool has slots and the host has memory (`fits`: the job's + `memory_mb` plus the runners started this pass, above a 2 GB reserve). It + writes the pool's heartbeat when the free count changes, and at least once a + minute. +4. On the runner, the job-started hook (`gate`) re-checks the run, the attempt + and the trust rule before the first step. +5. `fleet_watch` runs in each run that sent a job to the fleet. Every 30 s it + reads its own attempt's jobs. A fleet job queued with no runner for + `stuck_seconds` (10 minutes; in `fleet` mode `fleet_stuck_seconds`, 2 hours) + cancels the run with an error that names the job. It ends when every routed + fleet job has a runner. +6. `sweep`, every 10 minutes, reads the `fork-tests.yml` runs of the last 24 + hours (the latest attempt of each). It leaves an untrusted run alone, by the + same rule the dispatcher uses. In `auto` and `github` mode: + - it cancels a run whose fleet job has been queued 10 minutes, if + `fleet_watch` has not done so already; + - it re-runs a run (all jobs: GitHub-hosted, since the sweep is not a trusted + actor) when a fleet job waited 10 minutes and never started, or when a + fleet job failed with no failed step of its own (its runner was lost). A + run a person cancelled after a fleet runner took it stays cancelled; + - it never re-runs when the branch has moved on or is gone. + + In `fleet` mode it re-runs nothing. It cancels, with an error, a run whose + fleet job waited 2 hours. A re-run of failed jobs has no `fleet_watch`, so + this is its only bound. The sweep keeps no state between passes. + +## Races and limits + +- **Re-run race (fixed).** The owner re-runs a run between the dispatcher + reading the run and reading its jobs. The job then belongs to attempt 2, + but its runner would be made for attempt 1, so the gate would kill the job. + The dispatcher now skips a job whose `run_attempt` differs from the run it + read, and starts its runner on the next pass. +- **Re-run after a cancel (fixed).** A run is cancelled before its fleet job + started, so its runner is still idle, and the owner re-runs it. Without more, + the new job asks for the same labels, so GitHub could give it the old idle + runner, made for attempt 1, and the gate would kill it. Each job's `runs-on` + now adds the attempt to its label (`-a`), so a job of attempt 2 can + never be given a runner made for attempt 1. The dispatcher starts a runner + only for a label whose attempt is the run's. This is structural: it does not + depend on stopping the idle runner in time (that still happens, as cleanup). +- **Two runs in `auto` may both count one free slot.** Both route a job to the + same pool before the heartbeat drops. The extra job waits for the slot. If the + slot is not free within 10 minutes, `fleet_watch` cancels the run, and the + sweep re-runs it GitHub-hosted. The run is slower, but it does not stay red. +- **A stranger's queued job that could take a fleet runner** blocks that + runner's launch. Examples are a job asking only for `self-hosted` or + `fleet-vm-t3code`, or one copying a run's label from its public log. The + trusted job then waits and falls back as above (`auto`: GitHub-hosted after 10 + minutes; `fleet`: an error after 2 hours). The dispatcher's journal names the + job that blocked it. A stranger's run is not even queued until the owner + approves it (`all_external_contributors`), and #12 skips its jobs unless the + pull request removed that check. +- **A full `fleet`-mode run on the `vm` pool's 2 slots** queues up to 16 Linux + jobs, two at a time. A slow run can come near the 2-hour bound. `auto` is the + everyday mode. +- **No `Runner.Worker` above the hook.** This only happens if a runner version + renamed its worker. The gate then can only exit 1, and says so in the job log. + Later `always()` steps would run. The real-runner check below catches this + before the fleet is used. +- **An outdated template stops the fleet (fail closed).** A runner older than + GitHub's newest `actions/runner` release would update itself in its copy + before the job. The fleet does not rely on that. The dispatcher reads the + newest release once an hour, and the launcher's `ready` compares it with the + template's `.fork-fleet-version` (written in host step 2). When the template + is older, or has no version file, no runner starts on that pool. The + heartbeat then reports `free: 0` with a `why` that says to refresh the + template, so `auto` goes GitHub-hosted. Running jobs and cleanup go on. Fix: + host step 2 with the new release. If the release cannot be read, the last + version read is kept; before the first read, nothing starts. +- **Per-pool locks.** Each dispatcher takes `dispatch-.lock` in + `--lock-dir` (default `/run/fork-fleet`) for each pool it serves. Two + dispatchers can serve different pools, never the same one. +- **One pool's host fails.** A pass that cannot reach a pool's host (the + launcher fails or cannot be run) leaves that pool alone for the rest of the + pass: no cleanup, no start, and its heartbeat is `free: 0` with the reason. + The other pools go on as usual. +- The sweep adds 144 short runs a day to the Actions list ("Fork fleet sweep"). + The repository is public, so they cost no minutes. +- Upstream-sync runs, and pull requests opened by anyone but `scuttletoby` + (bots included), always run GitHub-hosted. Allowing them is a change to + `trusted` and an owner decision. + +## Host setup (VM106, the `vm` pool) + +Run as root on the host. Nothing here prints a secret. Use the `main` commit +that has this change. VM106 is the owner's workspace, not a VM of its own: the +runners' network (step 1b, the launcher, step 6) is what keeps jobs off its +services and its LAN. Steps 1b to 3 each run in a subshell with +`set -euo pipefail`, so the first failing command stops that step (and leaves +your shell open). Re-install the files (step 3) after any change on `main` to +the script, the config, the hook or the launcher, then +`systemctl restart t3code-fleet-dispatcher`. Until then, the host keeps the old +rule. + +```sh +# 1. One runner user per slot (the vm pool has 2): no home, no sudo, no docker group, no lingering, no cron or at. +# Safe to run again: an existing user is kept, and each deny line is added once. +for u in fork-runner-1 fork-runner-2; do + id "$u" >/dev/null 2>&1 || useradd --no-create-home --home-dir /nonexistent --user-group --shell /usr/sbin/nologin "$u" + id "$u" # expect only its own group: no sudo, no docker + loginctl disable-linger "$u" 2>/dev/null || true + for f in cron at; do # with an /etc/$f.allow, just leave the user out of it + [ -e /etc/$f.allow ] || grep -qx "$u" /etc/$f.deny 2>/dev/null || echo "$u" >> /etc/$f.deny + done +done +# The launcher refuses a runner user that is in another group, lingers, or is the same user as another slot. +# A shared user from an earlier install is no longer used: id fork-runner >/dev/null 2>&1 && userdel -r fork-runner + +# 1b. What the runners' network needs (the launcher makes each runner user's namespace and the nft table itself, +# at every start, and starts nothing without them): nftables, IPv4 forwarding on, and the jobs' resolv.conf +# (public nameservers: this host's resolver is on its loopback, which a runner's namespace does not have). +# NET_BASE (step 4; default 10.204, the runners' 10.204.0.0/16) must be a range nothing else on the host uses +# (another sandbox, a VPN, Docker networks). Safe to run again. +( + set -euo pipefail + command -v nft >/dev/null || apt-get install -y nftables + printf 'net.ipv4.ip_forward = 1\n' > /etc/sysctl.d/60-fork-fleet.conf + sysctl -q -p /etc/sysctl.d/60-fork-fleet.conf + install -d -m 700 /etc/fork-fleet + printf 'nameserver 1.1.1.1\nnameserver 9.9.9.9\n' > /etc/fork-fleet/resolv.conf + chmod 644 /etc/fork-fleet/resolv.conf + ! ip -4 route | grep -q '^10\.204\.' || echo "10.204 is in use here: pick another NET_BASE in step 4" +) + +# 2. The runner template: an unconfigured actions/runner release, owned by root, checked by sha256. +# Take the newest version and its linux-x64 sha256 from https://github.com/actions/runner/releases +# Run this again for each new release: the fleet starts no runner while the template is older than the newest one. +# /opt and /var/lib must be on one filesystem: each copy hard-links the template (the launcher checks it). +# The new template is checked (sha256, unconfigured, its version file) before anything is replaced; the old one +# stays in place until then, and a failure stops here with the dispatcher still running the old one. +( + set -euo pipefail + ver=X.Y.Z; sum=SHA256_FROM_THE_RELEASE_PAGE + [[ $ver =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && $sum =~ ^[0-9a-f]{64}$ ]] || { echo "set ver and sum first"; exit 1; } + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + new=/opt/fork-fleet/runner-template.new live=/opt/fork-fleet/runner-template old=/opt/fork-fleet/runner-template.old + curl -fsSLo "$tmp/runner.tgz" "https://github.com/actions/runner/releases/download/v$ver/actions-runner-linux-x64-$ver.tar.gz" + echo "$sum $tmp/runner.tgz" | sha256sum -c --strict - + install -d -m 755 /opt/fork-fleet + rm -rf "$new" && install -d -m 755 "$new" + tar -xzf "$tmp/runner.tgz" -C "$new" --no-same-owner + printf '%s\n' "$ver" > "$new/.fork-fleet-version" + chmod -R go-w "$new" + "$new/bin/installdependencies.sh" # the runner's own libraries + [ -x "$new/run.sh" ] && [ -x "$new/bin/Runner.Listener" ] && [ "$(cat "$new/.fork-fleet-version")" = "$ver" ] + for f in .env .path .runner .credentials .credentials_rsaparams; do [ ! -e "$new/$f" ]; done # never config.sh here + # Swap with no runner starting: stop the dispatcher (running jobs keep their own copies), swap, start it again. + systemctl stop t3code-fleet-dispatcher 2>/dev/null || true + rm -rf "$old" + [ ! -e "$live" ] || mv -T "$live" "$old" + mv -T "$new" "$live" + rm -rf "$old" + systemctl start t3code-fleet-dispatcher 2>/dev/null || true # on a first install, step 5 starts it + echo "template: actions/runner $(cat "$live/.fork-fleet-version")" +) + +# 3. The fleet files, from one pinned main commit. All four are fetched first and installed only when each came; +# SOURCE is written last, so it never names a commit whose files are not all in place. +( + set -euo pipefail + sha=FULL_MAIN_SHA + [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "set sha first"; exit 1; } + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + base=https://raw.githubusercontent.com/scuttletoby/t3code/$sha/.github + files="fork-fleet.json:fork-fleet.json:644 scripts/fork-fleet.py:fork-fleet.py:644 + scripts/fork-fleet-hook.sh:hook.sh:755 scripts/fork-fleet-launch.sh:fork-fleet-launch.sh:755" + for f in $files; do + IFS=: read -r src dst mode <<<"$f" + curl -fsSLo "$tmp/$dst" "$base/$src" + done + # It loads and its config passes: the gate then gets as far as its refusal outside a runner. + out=$(python3 -I "$tmp/fork-fleet.py" --config "$tmp/fork-fleet.json" gate &1 || true) + grep -q 'no Runner.Worker above this hook' <<<"$out" || { printf '%s\n' "$out"; exit 1; } + bash -n "$tmp/hook.sh" && bash -n "$tmp/fork-fleet-launch.sh" + install -d -m 755 /opt/fork-fleet + for f in $files; do + IFS=: read -r src dst mode <<<"$f" + install -o root -g root -m "$mode" "$tmp/$dst" "/opt/fork-fleet/$dst" + done + echo "$sha" > /opt/fork-fleet/SOURCE +) +python3 -I /opt/fork-fleet/fork-fleet.py --config /opt/fork-fleet/fork-fleet.json gate /etc/fork-fleet/launch.conf +chmod 600 /etc/fork-fleet/launch.conf +( umask 077; read -rsp 'token: ' t; printf '%s\n' "$t" > /etc/fork-fleet/token ); echo +# RUNNER_USERS: one user per slot of the pool (step 1). WORK_ROOT (/var/lib/fork-fleet/runners): the launcher makes +# it 0711. Every directory above it must let the runner users through (o+x), or no runner can reach its copy. +# NET_BASE (10.204) and RESOLV_CONF (/etc/fork-fleet/resolv.conf): add a line only to change one (step 1b). + +# 5. The dispatcher, for the vm pool only. +cat > /etc/systemd/system/t3code-fleet-dispatcher.service <<'EOF' +[Unit] +Description=t3code fork fleet dispatcher (vm pool) +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart=/usr/bin/python3 -I /opt/fork-fleet/fork-fleet.py --config /opt/fork-fleet/fork-fleet.json dispatch --pool vm +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload && systemctl enable --now t3code-fleet-dispatcher +journalctl -u t3code-fleet-dispatcher -n 20 # each runner it starts or skips is logged here + +# 6. Prove the runners' network, from inside a runner: check-net runs a probe as a free runner user, in the very +# sandbox a runner gets. The URL (public) must answer; each HOST:PORT must be closed from inside. The targets are +# every IPv4 service this host listens on, from each of its addresses: its loopback services, its LAN address, +# its Tailscale (100.64.0.0/10) address, its Docker bridges. check-net refuses a target the host itself does not +# reach (its refusal inside would prove nothing): if a listener just closed, run it again. Expect "ok" on every +# line and exit 0. Run it again after any network change on the host, and whenever a new service listens. +mapfile -t addrs < <(ip -4 -o addr show | awk '{ sub("/.*", "", $4); print $4 }') +mapfile -t targets < <(ss -4ltnH | awk -v addrs="${addrs[*]}" '{ + n = split($4, hp, ":"); h = hp[1]; p = hp[n]; sub(/%.*/, "", h) + if (h == "0.0.0.0" || h == "*") { k = split(addrs, a, " "); for (i = 1; i <= k; i++) print a[i] ":" p } else print h ":" p + }' | sort -u) +/opt/fork-fleet/fork-fleet-launch.sh check-net https://github.com "${targets[@]}"; echo "exit $?" +``` + +Do not name the dispatcher's unit `fork-fleet-*`: that prefix is for runner +units. Runner units are separate transient units, so restarting the dispatcher +leaves running jobs alone. + +**The `pc` pool** stays off until `/opt/fork-fleet/launch-pc` exists: a wrapper +that runs `fork-fleet-launch.sh` on the PC over ssh. Run the dispatcher with +`--pool vm` until then. Without its dispatcher, `pc` has no heartbeat, and +`route` never counts it. Whether and how the PC joins is an owner decision. + +**What jobs need on the host.** The jobs run without sudo, so the host must +already have: + +- `git`, `curl`, `unzip`, `python3` (3.8 or later, at `/usr/bin/python3`), + `pkg-config` and a C/C++ toolchain; +- `libsecret-1-dev` (Build and Test check `pkg-config --exists libsecret-1`); +- `xvfb` (Build checks for `xvfb-run`); +- Chromium's system libraries for the browser tests + (`npx playwright install-deps chromium`, once, as an admin). + +Bun, Node and Rust are installed per job by the setup actions, into the job's +own home, which goes with its copy. Leave a few tens of GB of disk for +`/var/lib/fork-fleet/runners`. + +## Owner steps on the repository + +Run these from a machine where `gh` is logged in as the owner. None of them +prints a secret. + +```sh +# 1. The setting. Optional: unset means auto. Use fleet or github to force a mode. +gh variable set CI_RUNNERS -R scuttletoby/t3code --body auto + +# 2. The dispatcher's token: a fine-grained personal access token with resource owner scuttletoby, +# "Only select repositories: scuttletoby/t3code", and these repository permissions only: +# Administration: Read and write (make, list and delete the one-job runners) +# Actions: Read-only (runs and jobs) +# Variables: Read and write (the heartbeats FLEET_BEACON_*) +# Pull requests: Read-only (the trust rule reads each pull request) +# Store it on the host only (host step 4). It is not a repository secret. + +# 3. The old route token is no longer used. Delete it if it was set. +gh secret delete FLEET_RUNNERS_TOKEN -R scuttletoby/t3code + +# 4. Check (names only). Approval for outside contributors must stay all_external_contributors. +gh variable list -R scuttletoby/t3code +gh api repos/scuttletoby/t3code/actions/permissions/fork-pr-contributor-approval --jq .approval_policy +``` + +## Real-runner check (before the fleet takes the fork's jobs) + +The tests model the runner. This check runs the real one. Use a **throwaway +private repository** under the owner's account, never the public fork with a +stranger. Creating that repository is the owner's call. Its only collaborator is +the owner. + +Set it up on VM106 next to the real install, with its own paths: + +- Install the same four files under `/opt/fork-fleet-proof/`. In its + `fork-fleet.json`, set `repository` to the proof repository, keep one pool + `proof` with label `fleet-proof-t3code`, `slots: 1`, `waits: true`, beacon + `FLEET_BEACON_PROOF` and launch + `["/usr/bin/env", "FORK_FLEET_LAUNCH_CONF=/etc/fork-fleet-proof/launch.conf", "/opt/fork-fleet-proof/fork-fleet-launch.sh"]`, + and one job `{"job": "lint", "memory_mb": 1024}`. +- In `/etc/fork-fleet-proof/launch.conf`, set `RUNNER_USERS=fork-runner-1,fork-runner-2`, + `TEMPLATE=/opt/fork-fleet/runner-template`, + `WORK_ROOT=/var/lib/fork-fleet-proof/runners`, `RUN_DIR=/run/fork-fleet-proof` + and `HOOK=/opt/fork-fleet-proof/hook.sh`. +- Store a token for the proof repository at `/etc/fork-fleet-proof/token`, with + the same permissions as above. +- Run the dispatcher by hand when a step says so: + `python3 -I /opt/fork-fleet-proof/fork-fleet.py --config /opt/fork-fleet-proof/fork-fleet.json dispatch --token-file /etc/fork-fleet-proof/token --lock-dir /run/fork-fleet-proof`. + +The proof repository's `.github/workflows/fork-tests.yml` needs two jobs. The +first writes `labels=["fleet-proof-t3code","fleet-proof-t3code-lint-<16 hex>-a${{ github.run_attempt }}"]` +with a random hex. The second, `lint`, has +`runs-on: ${{ fromJSON(needs..outputs.labels) }}` and these steps: + +- `echo job ran`; +- a step that must fail. It tries to write the template through its copy: + `! sh -c 'echo x >> "$GITHUB_WORKSPACE/../../../run.sh"' && ! touch "$GITHUB_WORKSPACE/../../../bin/proof"`; +- an `if: always()` step that runs `touch "$HOME/fleet-proof-always-ran"` (the + home is in the runner's copy, which stays until the dispatcher prunes it). + +Then check each of these: + +1. **A trusted push runs.** Push to `main` with the dispatcher running. The job + log shows `fork-fleet: allowed`, and the template step passes, so the runner + user cannot write the template. While the job runs, `ls /run/fork-fleet-proof/` + shows no `.env` file: the environment file is gone once the runner started. + Within a minute after the job, `systemctl list-units 'fork-fleet-*'` shows no + proof unit, and the dispatcher has pruned the copy (its home with it). +2. **A refused job runs no `always()` step.** Stop the dispatcher. Push again, so + `lint` queues. Start a runner by hand that is made for another run, with the + job's two labels from its log (the launcher picks a free runner user): + + ```sh + jit=$(gh api -X POST repos/OWNER/PROOF/actions/runners/generate-jitconfig -f name=fleet-proof-t3code-1 \ + -F runner_group_id=1 -f 'labels[]=fleet-proof-t3code' -f 'labels[]=LABEL_FROM_THE_JOB' -f work_folder=_work \ + --jq .encoded_jit_config) + printf 'ACTIONS_RUNNER_INPUT_JITCONFIG=%s\nFORK_FLEET_RUN=1\nFORK_FLEET_ATTEMPT=1\n' "$jit" | + FORK_FLEET_LAUNCH_CONF=/etc/fork-fleet-proof/launch.conf /opt/fork-fleet-proof/fork-fleet-launch.sh start fleet-proof-t3code-1 + unset jit + ``` + + The job is failed, none of its steps ran, and + `ls /var/lib/fork-fleet-proof/runners/fleet-proof-t3code-1/home/` shows no + `fleet-proof-always-ran`. The live log shows the + gate's `this runner was made for run 1`. The saved log may lack it, because + the worker died. Start the dispatcher again: it prunes the copy. + +3. **A bare label finds no runner.** A second workflow, not in `trusted.workflows`, + with `runs-on: [fleet-proof-t3code]`, stays queued, and the dispatcher starts + nothing for it. Push to `main` while it waits: the dispatcher holds that run's + `lint` back and logs which job blocks it. Cancel both. +4. **A re-run gets a runner of its own attempt.** Re-run only the `lint` job of + the first run, as the owner. Its label ends in `-a2`, it gets a new runner, + and the gate logs `attempt 2`. +5. **A cancelled run's idle runner takes no re-run.** Stop the dispatcher. Push, + so `lint` queues with a label ending in `-a1`, and cancel the run. Start a + runner by hand for it, as in check 2, but with `FORK_FLEET_RUN=` + and the job's `-a1` label: it stays idle, its job is gone. Re-run the run as + the owner. The new `lint` asks for `-a2` and stays queued: the idle runner + never takes it. Start the dispatcher: it starts a runner for attempt 2, which + passes, and stops the idle one. +6. **No slot reaches the other.** Set the proof pool to `slots: 2` and restart + the dispatcher. Give `lint` one more step before the others: + `echo leftover > "$HOME/planted"; echo leftover > /dev/shm/planted-$RANDOM; (setsid sleep 3000 &); sleep 120`, + and before it `! ls /dev/shm/planted-* && ! ls "$HOME/planted"` and the + network check `curl -fsS -o /dev/null https://github.com && ! timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/3773' && ! timeout 5 bash -c 'exec 3<>/dev/tcp/LAN_ADDRESS/22'` + with the host's LAN address for `LAN_ADDRESS` (the public internet answers; + T3 on the host's loopback and the host's LAN address do not). Push to + `main`, then push again within a minute, so two runs hold both slots. While + both sleep, as root: + `systemctl show -p User,WorkingDirectory 'fork-fleet-*'` shows two different + users, and `setpriv --reuid=fork-runner-1 --regid=fork-runner-1 --clear-groups ls ` + fails with "Permission denied" (and the other way round). After both jobs, + push again: the new job's first check passes (no file from the earlier job in + its home or `/dev/shm`, which is the unit's own), and while it runs that check, `pgrep -af 'sleep 3000'` + prints nothing. Set `slots: 1` again. + +Delete the proof install (`/opt/fork-fleet-proof`, `/etc/fork-fleet-proof`, +`/var/lib/fork-fleet-proof`) and the proof repository's runners afterwards. + +## Failure table + +The question for each row: what happens when this runs again, stops half-way, +or meets existing, old or empty state? GitHub (runs, jobs, runner registrations, +the heartbeat variables) and the host (systemd units, copies) hold all state. The +dispatcher keeps only pacing in memory, so a restart starts over cleanly. + +| Part | Situation | What happens | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Setting | `CI_RUNNERS` unset or empty | `auto`. | +| Setting | Misspelled | `Route` fails, so the run is red, and the message says why. Fix the variable and re-run. | +| Route | No heartbeat, an old one (over 180 s) or a garbled one | That pool counts no free slot. `auto`: GitHub-hosted, with a note (a `::notice::` on the run page when the pool has a heartbeat at all). `fleet`: queue on `vm`. | +| Route | A pool its dispatcher closed or reports down (a new actions/runner release, a host failure) | A `::notice::` on the run page names the pool and the reason, so `auto` never goes GitHub-hosted unseen for days. `auto`: GitHub-hosted. `fleet`: queue on `vm`. | +| Route | The `changes` outputs cannot be read | Every routed job counts as running, with a warning. | +| Route | Untrusted run (fork's pull request, bot, other actor, release run, the upstream sync's push) | GitHub-hosted in every mode. A release run skips `Route` and is GitHub-hosted. | +| Route | A push to `main` of a commit tagged `fork-follow/synced/` (the upstream sync moved `main` to it) | Untrusted: GitHub-hosted in every mode; the dispatcher makes no runner for it and the gate refuses it. When the tag cannot be read (GitHub or git fails), the run is untrusted too. | +| Route | The sync stops between its tag and `main` (killed, the `main` push refused) | The tag names a commit `main` does not have yet: harmless. The next sync tags it again (a no-op) and then moves `main`. A sync whose tag push fails does not move `main` and is red. | +| Route | Re-run of failed jobs | `route` does not run again. `auto` (attempt differs): GitHub-hosted. `fleet`: the old labels, while the setting is still `fleet`. `github` now: GitHub-hosted. | +| Route | Re-run of all jobs | `route` runs again with a new label: fleet as in a new run if the owner re-ran it, GitHub-hosted if the sweep or someone else did. | +| Route | Re-run of a run from before this change | That commit's workflow has no `Route`: GitHub-hosted. | +| Watch | A job waits past the bound | The run is cancelled with an error naming the job. `auto`: the sweep re-runs it GitHub-hosted within 10 minutes. | +| Watch | A routed job never shows up (a matrix job skipped) | After 2 minutes, the watch counts only the jobs it saw. | +| Watch | GitHub truncated, garbled or failed a reply | A warning. It tries again in 30 s, and after the bound it stops watching without turning red. The sweep is the fallback. | +| Watch | Re-run attempt | It watches its own attempt's jobs only. | +| Sweep | Stops half-way (cancelled, did not re-run) | The next sweep finds the cancelled run, whose fleet job waited 10 minutes and never started, and re-runs it. | +| Sweep | A cancel that takes over 2 minutes | It is left for the next sweep. | +| Sweep | Runs again on a run it re-ran | The re-run's triggering actor is `github-actions`, so the run is untrusted and left alone. Each run is re-run at most once. | +| Sweep | Two sweeps at once (manual and cron) | The concurrency group runs them one at a time. | +| Sweep | A stranger's run with fleet jobs | Left alone: never cancelled or re-run. | +| Sweep | A person cancels a run | Stands if a fleet runner had the job. A run cancelled while a fleet job had already waited 10 minutes is re-run, as the sweep would have done. | +| Sweep | Branch moved on or deleted | Not re-run. | +| Sweep | Runner lost mid-job (VM restart) | `auto`: re-run GitHub-hosted. `fleet`: stays red, and the owner re-runs it. | +| Sweep | A test fails on a fleet runner | Red, as on GitHub-hosted. Not re-run. | +| Sweep | GitHub trouble on one run | A warning, the sweep fails, and the next one tries again. | +| Sweep | `fleet` mode, a job waits 2 hours | The run is cancelled with an error, even a re-run of failed jobs that has no watch. Not re-run. | +| Dispatcher | Crash after `generate-jitconfig`, before `start` | An offline registration with no unit. The next pass deletes it, and the pass after starts the job's runner again (at most 3 tries per job, then it is left to the watch and the sweep). | +| Dispatcher | Crash after `start` | The unit is there. The next pass sees it and starts nothing twice. | +| Dispatcher | Launcher killed half-way through copying the template | No unit. The next pass deletes the registration, `prune` removes the half copy and any environment file, and the job's runner is started again. | +| Dispatcher | Restart, or a host reboot | It reads everything again from GitHub and systemd. The lock directory under `/run` is made again after a boot. After a reboot, a job that was running fails as a lost runner (`auto`: the sweep re-runs it GitHub-hosted). A job still queued has its offline registration deleted and gets a new runner. | +| Dispatcher | Two dispatchers for one pool | The second one fails on that pool's lock (`dispatch-.lock`). Two dispatchers for different pools both run. | +| Dispatcher | The job went away before its runner took it (cancelled) | The idle runner is stopped after 3 passes, or at once if another queued job asks for all of its labels. A re-run's job never can: its label names its own attempt. | +| Dispatcher | A queued job whose label names another attempt than its run | No runner: the gate would refuse it on any runner. The watch or the sweep handles it. | +| Dispatcher | Re-run between reading the run and its jobs | Skipped for one pass, then a runner is made for the new attempt. | +| Dispatcher | The PC or VM has no memory | No start, and the heartbeat shows 0 free slots. `auto`: new runs go GitHub-hosted. | +| Dispatcher | GitHub fails during a pass | A warning. The pass stops, and the next one, 20 s later, starts over. | +| Dispatcher | One pool's host fails (launcher error, cannot be run) | That pool is left alone for the rest of the pass: no cleanup, no start, heartbeat `free: 0` with the reason. Other pools go on. | +| Dispatcher | The runner template is older than the newest release, or has no `.fork-fleet-version` | No runner starts on that pool; cleanup goes on. Heartbeat `free: 0` with a `why` that says to refresh the template (host step 2). `auto`: GitHub-hosted. | +| Dispatcher | Token file missing, readable by anyone but root, or empty | It refuses to start. `systemctl status` shows why. | +| Dispatcher | Down for a while | Heartbeats go stale after 180 s. `auto`: GitHub-hosted. `fleet`: jobs queue, and the watch reports after 2 hours. | +| Gate | Run, attempt or trust rule does not match | `Runner.Worker` is killed before the first step: the job fails and no `always()` step runs. | +| Gate | Broken installed config | It refuses every job, failing closed. The dispatcher stops too. Re-install from `main`. | +| Gate | Installed script that does not even start (a syntax error) | The dispatcher fails on start too, so no runner starts. Restart the dispatcher after each re-install, so it never runs an older copy than the hook. | +| Gate | No `FORK_FLEET_RUN` (a runner not made by the dispatcher) | Refused. | +| Launcher | Template configured (`.env` and the like), a symlink, or writable by others | It refuses to start a runner, and the dispatcher logs the reason. | +| Launcher | `launch.conf` or the hook writable by anyone but root | It refuses. | +| Launcher | Bad input (an extra line, a key twice, a malformed value) | It refuses before touching anything. | +| Launcher | A name that ran already | It refuses. `prune` removes the old copy once its unit is gone. | +| Launcher | `systemd-run` fails | The environment file is removed on exit. The copy stays until `prune`. | +| Launcher | A runner that never gets its job, or hangs | Its unit stops after 1 hour (`MAX_SECONDS`), and its whole process group is killed. | +| Launcher | A job left a process (double fork, `setsid`), or files in its home, `/tmp` or `/dev/shm` | `/tmp`, `/var/tmp`, `/dev/shm` and `/run` were the unit's own and went with it. Before its runner user is used again, every process of that user is killed and its old copies (home inside) are deleted. If a process or copy stays, the launcher refuses to start. | +| Launcher | The runners' network cannot be made (no `nft`, IPv4 forwarding off, no nameserver in `RESOLV_CONF`, an `ip` or `nft` failure) | It refuses to start a runner and says why; the dispatcher marks the pool down (`free: 0` with the reason). Host step 1b, then step 6. | +| Launcher | The `fork_fleet` table removed or changed while runners run | The unit's `IPAddressDeny` still refuses the same ranges in the kernel. The next start makes the table again. | +| Launcher | `RUNNER_USERS` names one user twice, root, a user in another group, or a lingering one | It refuses. | +| Launcher | Every runner user has a unit | It refuses: no slot is free. | +| Launcher | `TEMPLATE` and `WORK_ROOT` on different filesystems | It refuses, with a message: `cp -al` cannot hard-link across filesystems. | +| Host setup | Step 1 or 2 run again | Step 1 keeps existing users and adds each deny line once. Step 2 stops the dispatcher, swaps the template with `mv -T`, and starts it again; a running job keeps its own copy. | +| Host setup | Step 1b, 2 or 3 fails half-way (a download, the checksum, a check) | The step stops at that command (`set -euo pipefail`). Step 2 has not touched the running template or the dispatcher yet; step 3 has installed nothing and not written `SOURCE`. | +| Host setup | Step 6 says a service is reachable from inside | Do not use the pool: stop the dispatcher until step 6 passes. | +| Old state | Leftover copies or environment files from an old install | `prune` removes any whose unit is gone, on every pass. | +| Old state | Old `FLEET_RUNNERS_TOKEN` secret, or a standing runner from the first design (`vm106-t3code`) | Unused. Delete both. A standing runner is what this design removes, because any job asking for its labels can reach it. The fork has no runners today. | +| Old state | Installed copy older than `main` | It applies the old rule until re-installed (host step 3). `SOURCE` names the commit. | +| Empty state | No runs, no runners, no variables yet | The dispatcher creates the heartbeat variables on its first pass. `route` counts no slot until then. | diff --git a/.github/scripts/fork-fleet-hook.sh b/.github/scripts/fork-fleet-hook.sh new file mode 100755 index 0000000000000..64279ae0d2876 --- /dev/null +++ b/.github/scripts/fork-fleet-hook.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Fork only: every fleet runner's job-started hook (ACTIONS_RUNNER_HOOK_JOB_STARTED, which fork-fleet-launch.sh sets +# from a root-only file). Installed root-owned next to root-owned copies of main's fork-fleet.py and fork-fleet.json; +# the runner user can change none of them. `gate` kills the job's Runner.Worker when the job may not run here. +exec /usr/bin/python3 -I "$(dirname -- "$0")/fork-fleet.py" --config "$(dirname -- "$0")/fork-fleet.json" gate diff --git a/.github/scripts/fork-fleet-launch.sh b/.github/scripts/fork-fleet-launch.sh new file mode 100755 index 0000000000000..ecc6f31140607 --- /dev/null +++ b/.github/scripts/fork-fleet-launch.sh @@ -0,0 +1,450 @@ +#!/usr/bin/env bash +# Fork only: starts and stops a fleet host's one-job runners, as root, for `fork-fleet.py dispatch` (a pool's `launch` +# command in .github/fork-fleet.json). Install a root-owned copy of main's file; runbook: .github/fork-fleet.md. +# +# start NAME stdin: the lines ACTIONS_RUNNER_INPUT_JITCONFIG=, FORK_FLEET_RUN= and FORK_FLEET_ATTEMPT=, nothing +# else. Runs a fresh copy of the runner template for this one job, in a transient systemd unit +# fork-fleet-NAME, as a runner user no other unit runs as (RUNNER_USERS: one per slot), in that user's +# network namespace (below). Before that user is used again, every process of it is killed, and so is +# every copy it ran in: HOME and TMPDIR are inside the copy, and /tmp, /var/tmp, /dev/shm and /run are +# the unit's own memory file systems, gone with it, so a job leaves nothing for the next one and root +# never deletes anything in a directory a runner user can write. The environment (the JIT config, the +# run it is for, HOME, TMPDIR and the job-started hook) comes from a root-only file, so nothing a +# runner user can write decides it. +# ready NEWEST exit 0 when the template's runner version (TEMPLATE/.fork-fleet-version, written by the runbook) is +# not older than NEWEST, the newest actions/runner release. An older runner would update itself in +# its copy before the job, which this fleet refuses: the owner refreshes the template instead. +# active the NAME of every fork-fleet-* unit that is loaded (starting, running or stopping). +# fits MB exit 0 when MemAvailable minus RESERVE_MB leaves MB megabytes. +# stop NAME stops that unit (a job on it fails: its runner is gone). +# prune removes the copies and environment files whose unit is gone. +# check-net URL HOST:PORT... +# the proof of the network (.github/fork-fleet.md, host step 6): runs a probe in a runner's real +# sandbox, as a free runner user. Exit 0 only when URL (public) answers and every HOST:PORT (services +# this host reaches: its loopback, its LAN address, Tailscale) is closed from inside. +# +# The network: each runner user has a namespace of its own, fork-fleet-USER, so a job has its own loopback and reaches +# no service on this host's (nor an abstract socket). Its only link, ffh-UID (host side), goes to the public internet: +# the nft table inet fork_fleet refuses the host itself, RFC 1918, CGNAT (Tailscale), link-local and every IPv6 packet, +# and the unit's IPAddressDeny refuses the same ranges again, in the kernel, should the table ever go. Every start +# (re)makes the namespace and the table under the launch lock and fails closed: no runner starts when either fails. +# +# Settings: /etc/fork-fleet/launch.conf (or $FORK_FLEET_LAUNCH_CONF), KEY=value lines, owned by this user and +# writable by it alone. RUNNER_USERS (user names, comma-separated: at least the pool's slots) and TEMPLATE (an +# unconfigured actions/runner, owned by this user, on WORK_ROOT's filesystem) are required; WORK_ROOT, RUN_DIR, HOOK, +# LINGER_DIR, RESERVE_MB, MAX_SECONDS, MEMINFO, NET_BASE (10.N: the runners' 10.N.0.0/16, one /30 per user from its +# uid, unused by anything else on the host), RESOLV_CONF (the jobs' resolv.conf: public nameservers, as this host's +# own resolver is on its loopback), IP_FORWARD, SYSTEMD_RUN and SYSTEMCTL have defaults below. +set -euo pipefail +umask 077 + +die() { + printf 'fork-fleet-launch: %s\n' "$*" >&2 + exit 1 +} + +# A path this user owns and no one else may write (not a symlink). +owned_safe() { + local uid mode + [[ -e $1 && ! -L $1 ]] || die "$1 is missing or a symlink" + read -r uid mode < <(stat -c '%u %a' -- "$1") + [[ $uid == "$EUID" ]] || die "$1 must belong to uid $EUID" + (((8#$mode & 8#022) == 0)) || die "$1 must be writable by its owner only" +} + +conf=${FORK_FLEET_LAUNCH_CONF:-/etc/fork-fleet/launch.conf} +owned_safe "$conf" +RUNNER_USERS='' TEMPLATE='' WORK_ROOT=/var/lib/fork-fleet/runners RUN_DIR=/run/fork-fleet HOOK=/opt/fork-fleet/hook.sh +LINGER_DIR=/var/lib/systemd/linger RESERVE_MB=2048 MAX_SECONDS=3600 MEMINFO=/proc/meminfo NET_BASE=10.204 +RESOLV_CONF=/etc/fork-fleet/resolv.conf IP_FORWARD=/proc/sys/net/ipv4/ip_forward SYSTEMD_RUN=systemd-run +SYSTEMCTL=systemctl +setting='^(RUNNER_USERS|TEMPLATE|WORK_ROOT|RUN_DIR|HOOK|LINGER_DIR|RESERVE_MB|MAX_SECONDS|MEMINFO|NET_BASE|RESOLV_CONF|IP_FORWARD|SYSTEMD_RUN|SYSTEMCTL)=([^[:space:]]*)$' +while IFS= read -r line || [[ -n $line ]]; do + [[ $line =~ ^[[:space:]]*(#|$) ]] && continue + [[ $line =~ $setting ]] || die "$conf: not a setting: ${line%%=*}" + printf -v "${BASH_REMATCH[1]}" '%s' "${BASH_REMATCH[2]}" +done <"$conf" +[[ -n $RUNNER_USERS && -n $TEMPLATE ]] || die "$conf: RUNNER_USERS and TEMPLATE are required" +[[ $RESERVE_MB =~ ^[0-9]{1,7}$ && $MAX_SECONDS =~ ^[1-9][0-9]{0,6}$ ]] || die "$conf: RESERVE_MB and MAX_SECONDS are numbers" +{ [[ $NET_BASE =~ ^10\.(0|[1-9][0-9]{0,2})$ ]] && ((BASH_REMATCH[1] <= 255)); } || die "$conf: NET_BASE is 10.N (N up to 255)" + +NAME='^[a-z0-9][a-z0-9-]{0,62}-[0-9]{1,20}$' +name_ok() { [[ $1 =~ $NAME ]] || die "not a runner name: $1"; } + +active() { + local unit rest + "$SYSTEMCTL" list-units --all --plain --no-legend --type=service 'fork-fleet-*' | while read -r unit rest; do + unit=${unit%.service} + unit=${unit#fork-fleet-} + if [[ $unit =~ $NAME ]]; then printf '%s\n' "$unit"; fi + done +} + +# The directories every verb that changes the host needs, and the lock that runs those verbs one at a time (two +# dispatchers, one per pool, may drive one host). WORK_ROOT: each runner user passes through it to its own copy +# (0711: it cannot list the others). RUN_DIR: root only. +setup() { + (umask 022 && mkdir -p -- "$WORK_ROOT") + mkdir -p -- "$RUN_DIR" + owned_safe "$WORK_ROOT" + owned_safe "$RUN_DIR" + chmod 0711 -- "$WORK_ROOT" + chmod 0700 -- "$RUN_DIR" + exec {lock}>"$RUN_DIR/launch.lock" + flock -w 100 "$lock" || die "another launcher holds $RUN_DIR/launch.lock" +} + +VERSION='^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,5}$' + +template_version() { + local v + [[ -f $TEMPLATE/.fork-fleet-version && ! -L $TEMPLATE/.fork-fleet-version ]] || + die "$TEMPLATE has no .fork-fleet-version: refresh the template (.github/fork-fleet.md, host step 2)" + v=$(<"$TEMPLATE/.fork-fleet-version") + [[ $v =~ $VERSION ]] || die "$TEMPLATE/.fork-fleet-version is not a version: refresh the template (host step 2)" + printf '%s\n' "$v" +} + +# Exit 0 when the template's runner is not older than NEWEST. A JIT runner older than the newest release updates +# itself in its copy before the job; this fleet does not rely on that, so it starts no runner until the owner +# refreshes the template. +ready() { + local have i + local -a a b + [[ $1 =~ $VERSION ]] || die "ready: not a version: $1" + owned_safe "$TEMPLATE" + have=$(template_version) + IFS=. read -ra a <<<"$have" + IFS=. read -ra b <<<"$1" + for i in 0 1 2; do + ((10#${a[i]} > 10#${b[i]})) && return 0 + ((10#${a[i]} < 10#${b[i]})) && + die "the runner template is actions/runner $have, older than the newest release $1: no runner starts until" \ + "the owner refreshes the template (.github/fork-fleet.md, host step 2)" + done + return 0 +} + +# The runner users: each a user of its own, in no group but its own, not lingering, with a network of its own. +check_users() { + local user uid gid groups + local -A seen=() nets=() + [[ $RUNNER_USERS =~ ^[a-z_][a-z0-9_-]{0,31}(,[a-z_][a-z0-9_-]{0,31})*$ ]] || die "$conf: RUNNER_USERS is not a list of user names" + for user in ${RUNNER_USERS//,/ }; do + uid=$(id -u -- "$user" 2>/dev/null) || die "no user $user" + gid=$(id -g -- "$user") + groups=$(id -G -- "$user") + [[ $uid != 0 && $uid != "$EUID" ]] || die "each runner user must be a user of its own, not $user" + [[ -z ${seen[$uid]+set} ]] || die "$conf: $user and ${seen[$uid]} are one user: each slot needs a user of its own" + seen[$uid]=$user + [[ $groups == "$gid" ]] || die "$user is in other groups ($groups): a runner user is in its own group only" + [[ ! -e $LINGER_DIR/$user ]] || die "$user lingers (loginctl disable-linger $user)" + [[ -z ${nets[$((uid % 16384))]+set} ]] || + die "$conf: $user and ${nets[$((uid % 16384))]} would share one network (a /30 from uid % 16384): give one another uid" + nets[$((uid % 16384))]=$user + done +} + +# The runner user a new unit runs as: one no loaded fork-fleet-* unit runs as. +free_user() { + local name names user busy=' ' + names=$(active) + for name in $names; do + user=$("$SYSTEMCTL" show --property=User --value -- "fork-fleet-$name.service") || die "no user of unit fork-fleet-$name" + [[ -n $user ]] || die "unit fork-fleet-$name runs as no runner user" + busy+="$user " + done + for user in ${RUNNER_USERS//,/ }; do + if [[ $busy != *" $user "* ]]; then + printf '%s\n' "$user" + return 0 + fi + done + die "every runner user ($RUNNER_USERS) has a unit: no slot is free" +} + +# Leaves nothing of a runner user's last job: its processes (kill(-1) as that user reaches every one of them, +# children started meanwhile included) and the copies it ran in (with their HOME and TMPDIR). Its /tmp, /var/tmp, +# /dev/shm and /run were the unit's own and went with it: nothing here deletes inside a directory a runner user can +# write (a path through one could be swapped for a symlink between a check and the delete). +reset_user() { + local user=$1 uid gid i rc + uid=$(id -u -- "$user") + gid=$(id -g -- "$user") + for ((i = 0; ; i++)); do + rc=0 + pgrep -u "$uid" >/dev/null || rc=$? + ((rc == 0 || rc == 1)) || die "pgrep failed ($rc)" + ((rc == 1)) && break + ((i < 20)) || die "processes of $user are still running" + # bash's own kill: kill(1) of util-linux and of procps read these arguments differently. + setpriv --reuid="$uid" --regid="$gid" --clear-groups -- /bin/bash --norc --noprofile -c 'kill -s KILL -- -1' \ + 2>/dev/null || true + sleep 0.1 + done + prune_now + [[ -z $(find "$WORK_ROOT" -mindepth 1 -maxdepth 1 -user "$uid" -print -quit) ]] || + die "a copy of $user is left in $WORK_ROOT" +} + +fits() { + local kb + [[ $1 =~ ^[0-9]{1,7}$ ]] || die "fits: not a number of megabytes: $1" + kb=$(awk '$1 == "MemAvailable:" { print $2 }' "$MEMINFO") + [[ $kb =~ ^[0-9]+$ ]] || die "no MemAvailable in $MEMINFO" + ((kb / 1024 - RESERVE_MB >= $1)) +} + +# The ranges no job reaches: the host itself is refused on its own (the input chain), these on the way out: RFC 1918, +# CGNAT (Tailscale and the like), link-local, loopback, the IETF and benchmark ranges, multicast and the reserved rest. +PRIVATE4=(0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 + 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4) + +must() { "$@" || die "network: $* failed: no runner starts without its network (.github/fork-fleet.md, host step 1)"; } + +# Makes what is missing of runner user $1's network and (re)applies the table; changes nothing that is right already +# and interrupts no connection (the table is replaced in one transaction; conntrack keeps the flows). Dies on any +# failure, so start never runs a unit without it. Under the launch lock: two launchers never apply it at once. +net_up() { + local user=$1 uid ns host peer off net gw addr private + uid=$(id -u -- "$user") + ns=fork-fleet-$user host=ffh-$uid peer=ffp-$uid + # Its /30 in NET_BASE.0.0/16: the host side .1, the namespace .2 (off is a multiple of 4, so both stay in it). + off=$((uid % 16384 * 4)) + net=$NET_BASE.$((off / 256)) + gw=$net.$((off % 256 + 1)) addr=$net.$((off % 256 + 2)) + [[ $(<"$IP_FORWARD") == 1 ]] || + die "network: $IP_FORWARD is not 1: the runners would reach nothing (.github/fork-fleet.md, host step 1)" + private=${PRIVATE4[*]} + must nft -f - </dev/null 2>&1 || must ip netns add "$ns" + ip link show "$host" >/dev/null 2>&1 || must ip link add "$host" type veth peer name "$peer" netns "$ns" + must ip addr replace "$gw/30" dev "$host" + must ip link set "$host" up + must ip -n "$ns" link set lo up + must ip -n "$ns" addr replace "$addr/30" dev "$peer" + must ip -n "$ns" link set "$peer" up + must ip -n "$ns" route replace default via "$gw" +} + +# Docker's FORWARD policy is DROP: on a host with Docker, what the table lets through needs these two rules too. Only +# accepts, so without them a job reaches less, never more; a host without the chain needs none. +# shellcheck disable=SC2086 # $r is split into iptables' arguments on purpose +docker_rules() { + local r rc said + command -v iptables >/dev/null 2>&1 && iptables -w 60 -S DOCKER-USER >/dev/null 2>&1 || return 0 + for r in "-o ffh-+ -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" "-i ffh-+ -j ACCEPT"; do + rc=0 + said=$(iptables -w 60 -C DOCKER-USER $r 2>&1) || rc=$? + case $rc in # 1 is the "no" of -C. Anything else, like a limit (4), is no answer at all + 0) ;; + 1) must iptables -w 60 -I DOCKER-USER 1 $r ;; + *) die "network: iptables -C DOCKER-USER gave no answer (exit $rc): $said" ;; + esac + done +} + +# The jobs' resolv.conf: this host's resolver listens on its loopback, which a job's namespace does not have. +resolv_target() { + local target + owned_safe "$RESOLV_CONF" + [[ -f $RESOLV_CONF && $RESOLV_CONF =~ ^/[A-Za-z0-9._/-]+$ ]] || die "$RESOLV_CONF: not a plain file path" + grep -q '^nameserver ' -- "$RESOLV_CONF" || die "$RESOLV_CONF names no nameserver (.github/fork-fleet.md, host step 1)" + target=$(readlink -f -- /etc/resolv.conf) + [[ $target =~ ^/[A-Za-z0-9._/-]+$ ]] || die "/etc/resolv.conf leads to $target: not a plain file path" + printf '%s\n' "$target" +} + +# The sandbox of every runner unit, and of check-net's probe (which proves it): the props array. Its own network +# namespace, the private ranges refused again in the kernel, its own /run, /dev/shm, /tmp and /var/tmp (memory, gone +# with the unit), no /home, no other user's processes, its own IPC, and the system read-only. +sandbox() { + local user=$1 resolv=$2 + props=( + -p "NetworkNamespacePath=/run/netns/fork-fleet-$user" + -p "IPAddressDeny=${PRIVATE4[*]} ::/0" -p IPAddressAllow=localhost + -p "TemporaryFileSystem=/run /dev/shm:size=1G,mode=1777 /tmp:size=1G,mode=1777 /var/tmp:size=1G,mode=1777" + -p "BindReadOnlyPaths=$RESOLV_CONF:$resolv" + -p NoNewPrivileges=yes -p ProtectSystem=strict -p ProtectHome=yes -p ProtectProc=invisible -p PrivateIPC=yes + -p RemoveIPC=yes + ) +} + +start() { + local name=$1 dir env_file line key value user resolv + local -a props + local -A got=() + name_ok "$name" + while IFS= read -r line; do + [[ $line =~ ^(ACTIONS_RUNNER_INPUT_JITCONFIG|FORK_FLEET_RUN|FORK_FLEET_ATTEMPT)=(.*)$ ]] || die "start: an unexpected input line" + key=${BASH_REMATCH[1]} value=${BASH_REMATCH[2]} + [[ -z ${got[$key]+set} ]] || die "start: $key twice" + case $key in + # A length check of its own: bash's regex refuses a bound above 255 (it would refuse every config). + ACTIONS_RUNNER_INPUT_JITCONFIG) [[ $value =~ ^[A-Za-z0-9+/=]+$ ]] && ((${#value} <= 65536)) ;; + *) [[ $value =~ ^[1-9][0-9]{0,19}$ ]] ;; + esac || die "start: $key is not well formed" + got[$key]=$value + done + ((${#got[@]} == 3)) || die "start: needs ACTIONS_RUNNER_INPUT_JITCONFIG, FORK_FLEET_RUN and FORK_FLEET_ATTEMPT" + + # The template: an unconfigured runner nobody but this user can change (each copy hard-links its files). + owned_safe "$TEMPLATE" + [[ -x $TEMPLATE/run.sh ]] || die "$TEMPLATE has no run.sh" + for f in .env .path .runner .credentials .credentials_rsaparams; do + [[ ! -e $TEMPLATE/$f ]] || die "$TEMPLATE/$f exists: the template must be an unconfigured runner (.github/fork-fleet.md)" + done + [[ -z $(find "$TEMPLATE" \( ! -uid "$EUID" -o \( ! -type l -perm /022 \) \) -print -quit) ]] || + die "$TEMPLATE has a file that is not this user's or that others may write" + owned_safe "$HOOK" + template_version >/dev/null + check_users + resolv=$(resolv_target) + + setup + # cp -al hard-links the template's files: it cannot cross filesystems (and a full copy would be writable). + [[ $(stat -c %d -- "$TEMPLATE") == "$(stat -c %d -- "$WORK_ROOT")" ]] || + die "TEMPLATE ($TEMPLATE) and WORK_ROOT ($WORK_ROOT) must be on one filesystem: cp -al hard-links the template" + dir=$WORK_ROOT/$name + env_file=$RUN_DIR/$name.env + [[ ! -e $dir ]] || die "$dir exists: a runner of that name ran already (prune removes it once its unit is gone)" + user=$(free_user) + reset_user "$user" + net_up "$user" + sandbox "$user" "$resolv" + # Expanded now: the trap runs at exit, after this function's locals are gone (set -u would fail it, file left). + # shellcheck disable=SC2064 + trap "rm -f -- $(printf '%q' "$env_file")" EXIT + cp -al -- "$TEMPLATE" "$dir" + # The copy's top directory, HOME and TMPDIR are the runner user's, and no one else's: no other runner user can + # read its JIT config, tokens or caches. Everything else in it is the template's (root's, read-only). + mkdir -m 0700 -- "$dir/home" "$dir/tmp" + chown -- "$user:" "$dir" "$dir/home" "$dir/tmp" + chmod 0700 -- "$dir" + { + printf 'ACTIONS_RUNNER_INPUT_JITCONFIG=%s\n' "${got[ACTIONS_RUNNER_INPUT_JITCONFIG]}" + printf 'FORK_FLEET_RUN=%s\nFORK_FLEET_ATTEMPT=%s\n' "${got[FORK_FLEET_RUN]}" "${got[FORK_FLEET_ATTEMPT]}" + printf 'HOME=%s\nTMPDIR=%s\n' "$dir/home" "$dir/tmp" + printf 'ACTIONS_RUNNER_HOOK_JOB_STARTED=%s\n' "$HOOK" + } >"$env_file" + # Type=exec: systemd-run returns once run.sh started, so the environment file has been read and can go. + # ProtectSystem=strict: the job writes its copy and its own memory file systems, nothing else. + "$SYSTEMD_RUN" --quiet --unit "fork-fleet-$name" --collect --uid "$user" --working-directory "$dir" \ + -p Type=exec -p KillMode=control-group -p "EnvironmentFile=$env_file" "${props[@]}" -p "ReadWritePaths=$dir" \ + -p "RuntimeMaxSec=$MAX_SECONDS" -- "$dir/run.sh" +} + +# Runs in the sandbox as the runner user: $1 the URL that must answer, then HOST PORT pairs that must be closed. +read -r -d '' PROBE <<'EOF' || true +url=$1 bad=0 +shift +while (($# >= 2)); do + if timeout 5 bash -c 'exec 3<>"/dev/tcp/$0/$1"' "$1" "$2" 2>/dev/null; then + echo "FAIL $1:$2 is reachable from inside" + bad=1 + else + echo "ok $1:$2 is closed" + fi + shift 2 +done +if code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 -- "$url"); then + echo "ok $url answers (HTTP $code)" +else + echo "FAIL $url does not answer" + bad=1 +fi +exit "$bad" +EOF + +check_net() { + local url=$1 target host port user resolv rc=0 + local -a pairs=() props + shift + [[ $url =~ ^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?$ ]] || die "check-net: not a plain http(s) URL: $url" + (($# > 0)) || die "check-net: name at least one HOST:PORT that must be closed" + for target; do + [[ $target =~ ^([0-9]{1,3}(\.[0-9]{1,3}){3}):([0-9]{1,5})$ ]] || die "check-net: not an IPv4 HOST:PORT: $target" + host=${BASH_REMATCH[1]} port=${BASH_REMATCH[3]} + # shellcheck disable=SC2016 # $0 and $1 are the inner bash's own arguments + timeout 5 bash -c 'exec 3<>"/dev/tcp/$0/$1"' "$host" "$port" 2>/dev/null || + die "check-net: this host does not reach $target either, so its refusal inside would prove nothing:" \ + "name a service this host reaches" + pairs+=("$host" "$port") + done + check_users + resolv=$(resolv_target) + setup + user=$(free_user) + reset_user "$user" + net_up "$user" + sandbox "$user" "$resolv" + echo "check-net: as $user, in its namespace fork-fleet-$user" + "$SYSTEMD_RUN" --quiet --wait --pipe --collect --unit fork-fleet-check-net --uid "$user" "${props[@]}" \ + -p RuntimeMaxSec=120 -- /bin/bash --norc --noprofile -c "$PROBE" check-net "$url" "${pairs[@]}" = 3)) || die "usage: check-net URL HOST:PORT..." + shift + check_net "$@" + ;; + *) die "usage: start NAME | ready NEWEST | active | fits MB | stop NAME | prune | check-net URL HOST:PORT..." ;; +esac diff --git a/.github/scripts/fork-fleet.py b/.github/scripts/fork-fleet.py new file mode 100644 index 0000000000000..33d572d7d088c --- /dev/null +++ b/.github/scripts/fork-fleet.py @@ -0,0 +1,973 @@ +#!/usr/bin/env python3 +"""Fork only: where fork-tests.yml's Linux jobs run, the repository's own fleet of self-hosted runners or GitHub-hosted. + +Every fact about the repository and its fleet is in one file, .github/fork-fleet.json (CONFIG); none is here. Runbook, +owner steps, the design and the failure table: .github/fork-fleet.md. + + python3 -I fork-fleet.py route the last step of fork-tests.yml's `changes` job (GitHub-hosted). Writes to + $GITHUB_OUTPUT: runners=, mode, attempt, watch. + A job key is the job id, or - for a sharded job. + python3 -I fork-fleet.py watch fork-tests.yml's `fleet_watch` job (GitHub-hosted, actions: write): a fleet job of + its run that waits too long for a runner cancels the run with an error. + python3 -I fork-fleet.py sweep fork-fleet.yml every 10 minutes (GitHub-hosted, actions: write): in auto and github + mode, a trusted run that the fleet did not finish is re-run once, GitHub-hosted; + in fleet mode, a run whose fleet job waited 2 hours is cancelled with an error. + python3 -I fork-fleet.py dispatch a fleet host's dispatcher, as root, from an installed copy of main's script and + CONFIG: starts a one-job runner for each queued fleet job of a trusted run. + python3 -I fork-fleet.py gate every fleet runner's job-started hook, from the same installed copy: a job that + breaks the rule has its Runner.Worker killed before the job's first step. + +The setting, repository variable CONFIG["variable"] (route and sweep read it from $FLEET_MODE): + auto (also unset) a job goes to a pool whose dispatcher reports a free slot, GitHub-hosted otherwise. Anything + unclear (no heartbeat, an old or unreadable one) is GitHub-hosted with a note: auto never makes a + run red. + fleet every job that runs goes to the fleet, pools with a free slot first, the rest queue on the pool + marked `waits`. A job that only prints a skip stays GitHub-hosted. Untrusted runs go hosted. + github every job GitHub-hosted, as before the fleet. + +No fleet runner waits for work. The dispatcher makes a one-job (JIT) runner for one queued job at a time, and only when: +the job asks for exactly a pool's label plus the per-run random label `route` made for it, which the job's runs-on ends +with its own attempt (so a runner made for one attempt can never be given another's job); GitHub's own record of the +run passes the trust rule (`refused`: a workflow in trusted.workflows of this repository, started and triggered by a +trusted actor, as a push to a trusted branch or a pull request whose head and base are this repository and which a +trusted actor opened); and no other queued job could take that runner. A job that names a fleet label by itself (a +changed workflow, a pull request from a fork) finds no runner. The job-started hook (`gate`) is the second line: it +refuses any job but the run and attempt its runner was made for, or one that breaks the rule, by killing the job's +Runner.Worker, so no later step runs, `if: always()` ones included. +Standard library only: a fleet host copies this file and CONFIG, nothing else. +""" +import argparse, calendar, http.client, json, os, re, secrets, signal, subprocess, sys, time +import urllib.error, urllib.parse, urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +CONFIG = os.path.join(os.path.dirname(HERE), 'fork-fleet.json') +MODES = ('auto', 'fleet', 'github') +API = os.environ.get('GITHUB_API_URL') or 'https://api.github.com' +# Labels a self-hosted runner may get without asking, or a job may ask for without naming a pool. A pool's label is none +# of them, and the dispatcher counts all of them as its runner's own when it checks that no other job could take it. +GENERIC = frozenset(('self-hosted', 'linux', 'x64', 'arm64', 'arm', 'windows', 'macos')) +LABEL = re.compile(r'[a-z0-9][a-z0-9-]{0,62}') +VARIABLE = re.compile(r'(?!GITHUB_)[A-Z][A-Z0-9_]{0,99}') +JOB_ID = re.compile(r'[a-z0-9_]{1,40}') +POOL = re.compile(r'[a-z0-9][a-z0-9_-]{0,39}') +# The run's label for one job: --<16 hex, made by route>-a. +NONCE = re.compile(r'(?P[a-z0-9_]{1,40}(?:-[0-9]{1,3})?)-[0-9a-f]{16}-a(?P[1-9][0-9]{0,4})') +VERSION = re.compile(r'v?([0-9]{1,5})\.([0-9]{1,5})\.([0-9]{1,5})') +JIT = re.compile(r'[A-Za-z0-9+/=]{1,65536}') +SHA = re.compile(r'[0-9a-f]{40}') +TAG_PREFIX = re.compile(r'[a-z0-9][a-z0-9/_-]{0,58}/') +SYNC_REMOTE = 'https://github.com/{}.git' # .format(repository): read anonymously, the repository is public +SYNC_CHECK_SECONDS = 30 +WINDOW = 24 * 3600 # the sweep looks at runs created this long ago at most +CANCEL_WAIT = 120 # seconds the sweep watches a run it cancelled, to re-run it in the same pass +SHOW_UP = 120 # seconds the watch waits for every routed fleet job to be listed (a skipped one never is) +FAILED = ('failure', 'timed_out') +MAX_TRIES = 3 # runners the dispatcher starts for one job before it leaves the job to the watch and the sweep +RUNNER_GROUP = 1 # the repository's default runner group +WORKER = 'Runner.Worker' + + +def say(msg): + print(msg, flush=True) + + +def load(path=CONFIG): + with open(path, encoding='utf-8') as f: + cfg = json.load(f) + problems = check_config(cfg) + if problems: + raise ValueError(f'{path}: ' + '; '.join(problems)) + return cfg + + +def positive(x): + return isinstance(x, int) and not isinstance(x, bool) and x > 0 + + +def check_config(cfg): + out = [] + for key in ('repository', 'variable', 'hosted', 'trusted', 'upstream_sync', 'pools', 'jobs', 'stuck_seconds', + 'fleet_stuck_seconds', 'beacon_fresh_seconds', 'dispatch'): + if key not in cfg: + out.append(f'no {key!r}') + if out: + return out + for key in ('stuck_seconds', 'fleet_stuck_seconds', 'beacon_fresh_seconds'): + if not positive(cfg[key]): + out.append(f'{key} must be a positive whole number') + for key in ('every_seconds', 'beacon_seconds', 'idle_passes'): + if not positive((cfg['dispatch'] or {}).get(key)): + out.append(f'dispatch.{key} must be a positive whole number') + prefix = (cfg['upstream_sync'] or {}).get('tag_prefix') + if not (isinstance(prefix, str) and TAG_PREFIX.fullmatch(prefix)): + out.append('upstream_sync.tag_prefix must be a tag name prefix ending in / (a-z, 0-9, /, _ and -)') + pools = cfg['pools'] + for field in ('name', 'label', 'beacon'): + got = [p.get(field) for p in pools] + if len(set(map(str, got))) != len(got): + out.append(f'two pools with one {field}') + for p in pools: + who = f'pool {p.get("name")!r}' + if not (isinstance(p.get('name'), str) and POOL.fullmatch(p['name'])): + out.append(f'{who}: name must be a-z, 0-9, _ and - (it names the dispatcher\'s lock file)') + if not (isinstance(p.get('label'), str) and LABEL.fullmatch(p['label']) and p['label'] not in GENERIC): + out.append(f'{who}: label must be one label of its own (a-z, 0-9, -), none of {sorted(GENERIC)}') + if not positive(p.get('slots')): + out.append(f'{who}: slots must be a positive whole number') + if not (isinstance(p.get('beacon'), str) and VARIABLE.fullmatch(p['beacon'])): + out.append(f'{who}: beacon must be a repository variable name (A-Z, 0-9, _; not GITHUB_)') + launch = p.get('launch') + if not (isinstance(launch, list) and launch and all(isinstance(x, str) and x for x in launch)): + out.append(f'{who}: launch must be a command list') + if not isinstance(p.get('waits'), bool): + out.append(f'{who}: waits must be true or false') + if sum(1 for p in pools if p.get('waits') is True) != 1: + out.append('exactly one pool must have "waits": true (where fleet mode queues)') + for j in cfg['jobs']: + if not (isinstance(j.get('job'), str) and JOB_ID.fullmatch(j['job'])): + out.append(f'job {j.get("job")!r}: the id must be a-z, 0-9 and _') + if not positive(j.get('memory_mb')): + out.append(f'job {j.get("job")!r}: memory_mb must be a positive number') + if j.get('shards') is not None and not (positive(j['shards']) and j['shards'] < 1000): + out.append(f'job {j.get("job")!r}: shards must be 1 to 999') + if out: + return out + keys = [k for k, _ in job_keys(cfg)] + if len(set(keys)) != len(keys): + out.append('a job key twice') + return out + + +def job_keys(cfg): + """[(key, job entry)] in CONFIG's order (its preference: the longest jobs first).""" + out = [] + for j in cfg['jobs']: + n = j.get('shards') + out += [(f'{j["job"]}-{i}', j) for i in range(1, n + 1)] if n else [(j['job'], j)] + return out + + +def mode_of(value): + """The setting as one of MODES; ValueError for anything else (a typo must not quietly mean auto).""" + got = (value or '').strip().lower() or 'auto' + if got not in MODES: + raise ValueError(f'the repository variable is {value!r}, not one of {", ".join(MODES)} (or unset: auto)') + return got + + +def stamp(text): + return calendar.timegm(time.strptime(text, '%Y-%m-%dT%H:%M:%SZ')) if text else None + + +def fleet_labels(cfg): + return {p['label'] for p in cfg['pools']} + + +def is_fleet(cfg, job): + return bool(set(job.get('labels') or ()) & fleet_labels(cfg)) + + +# ---------------------------------------------------------------- the rule: route, dispatcher, sweep and the hook + +def refused(cfg, env, event): + """Why this run may NOT use the fleet, or None when it may. env: the GITHUB_* variables; event: the payload.""" + repo, trusted = cfg['repository'], cfg['trusted'] + if env.get('GITHUB_REPOSITORY') != repo: + return f'repository {env.get("GITHUB_REPOSITORY")!r}, not {repo}' + ref = env.get('GITHUB_WORKFLOW_REF') or '' + if not any(ref.startswith(f'{repo}/{w}@') for w in trusted['workflows']): + return f'workflow {ref!r} is not one of {trusted["workflows"]}' + for name in ('GITHUB_ACTOR', 'GITHUB_TRIGGERING_ACTOR'): + if env.get(name) not in trusted['actors']: + return f'{name} is {env.get(name)!r}, not a trusted actor' + if not isinstance(event, dict): + return 'no event payload' + kind = env.get('GITHUB_EVENT_NAME') + if kind == 'push': + branches = [f'refs/heads/{b}' for b in trusted['push_branches']] + if env.get('GITHUB_REF') not in branches: + return f'a push to {env.get("GITHUB_REF")!r}' + if (event.get('repository') or {}).get('full_name') != repo: + return 'a push to another repository' + return made_by_the_sync(cfg, env.get('GITHUB_SHA')) + if kind != 'pull_request': + return f'a {kind!r} event' + pr = event.get('pull_request') or {} + for side in ('head', 'base'): + got = ((pr.get(side) or {}).get('repo') or {}).get('full_name') + if got != repo: + return f'a pull request whose {side} is {got!r}' + if (pr.get('user') or {}).get('login') not in trusted['actors']: + return f'a pull request opened by {(pr.get("user") or {}).get("login")!r}' + return None + + +def made_by_the_sync(cfg, sha, run=subprocess.run): + """Why a push of commit `sha` may not use the fleet because the upstream sync made it, or None. + + Owner decision: upstream's code runs on GitHub-hosted runners only. The sync (fork-follow-push.sh advance) moves + main with the owner's token, so its push run passes every other check here. It first tags the commit + in this repository and moves main only when that push worked. A tag here can only + be made by a holder of push rights (the owner, the sync's token): nothing an upstream commit holds (its message, + author, files, a branch or pull request upstream) makes or removes one. And a tag only ever sends a run to + GitHub-hosted runners, so the worst a stray tag does is that. Read anonymously (the job hook has no token); any + doubt (no full sha, git failing, a timeout) refuses.""" + if not (isinstance(sha, str) and SHA.fullmatch(sha)): + return f'the pushed commit {sha!r} is not a full sha, so whether the upstream sync made it cannot be told' + ref = f'refs/tags/{cfg["upstream_sync"]["tag_prefix"]}{sha}' + plain = {'PATH': '/usr/bin:/bin', 'LC_ALL': 'C', 'GIT_TERMINAL_PROMPT': '0', 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_CONFIG_GLOBAL': '/dev/null'} # no git config of whoever runs this (the job hook: a runner user) + try: + got = run(['git', '-c', 'protocol.version=2', 'ls-remote', '--exit-code', SYNC_REMOTE.format(cfg['repository']), + ref], env=plain, stdin=subprocess.DEVNULL, capture_output=True, text=True, + timeout=SYNC_CHECK_SECONDS, check=False) + except (OSError, subprocess.SubprocessError) as e: + return f'whether the upstream sync made {sha[:12]} cannot be told ({e.__class__.__name__})' + if got.returncode == 2: # --exit-code: the remote has no such ref + return None + if got.returncode == 0: + return (f'commit {sha[:12]} was pushed by the upstream sync (tag {ref}): upstream code runs on ' + 'GitHub-hosted runners only') + return f'whether the upstream sync made {sha[:12]} cannot be told (git ls-remote exit {got.returncode})' + + +def facts_of_run(run): + """The GITHUB_* variables `refused` reads, from GitHub's own record of a workflow run (never from a job).""" + repo = (run.get('repository') or {}).get('full_name') + path = (run.get('path') or '').split('@')[0] + return {'GITHUB_REPOSITORY': repo, 'GITHUB_WORKFLOW_REF': f'{repo}/{path}@refs/heads/{run.get("head_branch")}', + 'GITHUB_ACTOR': (run.get('actor') or {}).get('login'), + 'GITHUB_TRIGGERING_ACTOR': (run.get('triggering_actor') or {}).get('login'), + 'GITHUB_EVENT_NAME': run.get('event'), 'GITHUB_REF': f'refs/heads/{run.get("head_branch")}', + 'GITHUB_SHA': run.get('head_sha')} + + +def run_refused(cfg, gh, run): + """`refused` for a run as GitHub records it. A pull request run must list its pull requests (GitHub lists none for + one from a fork); each is read from pulls/, and its head must be the run's head repository.""" + env = facts_of_run(run) + if env['GITHUB_EVENT_NAME'] != 'pull_request': + return refused(cfg, env, {'repository': run.get('repository') or {}}) + prs = run.get('pull_requests') or [] + if not prs: + return 'a pull request run with no pull request of this repository (one from a fork)' + head = (run.get('head_repository') or {}).get('full_name') + for p in prs: + pull = gh.call(f'pulls/{int(p["number"])}') + got = ((pull.get('head') or {}).get('repo') or {}).get('full_name') + if got != head: + return f'pull request #{p["number"]} has head {got!r}, the run {head!r}' + why = refused(cfg, env, {'pull_request': {'head': {'repo': {'full_name': head}}, 'base': pull.get('base'), + 'user': pull.get('user')}}) + if why: + return why + return None + + +def read_event(env): + path = env.get('GITHUB_EVENT_PATH') + try: + with open(path, encoding='utf-8') as f: + return json.load(f), None + except (TypeError, OSError, ValueError) as e: + return None, f'the event file {path!r} cannot be read ({e.__class__.__name__})' + + +def made_for(env): + """Why this job is not the one its runner was made for, or None. The dispatcher's launcher puts FORK_FLEET_RUN and + FORK_FLEET_ATTEMPT in the runner's environment from a root-only file; a workflow cannot set the hook's environment.""" + want = (env.get('FORK_FLEET_RUN'), env.get('FORK_FLEET_ATTEMPT')) + got = (env.get('GITHUB_RUN_ID'), env.get('GITHUB_RUN_ATTEMPT')) + if not all(want): + return 'this runner was not started by the dispatcher (no FORK_FLEET_RUN in its environment)' + if want != got: + return f'this runner was made for run {want[0]} attempt {want[1]}, not run {got[0]} attempt {got[1]}' + return None + + +def worker_above(pid=None, proc='/proc'): + """The pid of the nearest Runner.Worker among this process's ancestors, or None.""" + pid = os.getppid() if pid is None else pid + for _ in range(64): + if pid <= 1: + return None + try: + with open(f'{proc}/{pid}/stat', encoding='utf-8', errors='replace') as f: + stat = f.read() + except OSError: + return None + comm, rest = stat[stat.index('(') + 1:stat.rindex(')')], stat[stat.rindex(')') + 2:].split() + if comm == WORKER: + return pid + pid = int(rest[1]) + return None + + +def stop_job(why, kill=os.kill, find=worker_above, pause=time.sleep): + """Refuse the job for good: an exit code alone would let the job's `if: always()` steps run.""" + say(f'::error::fork-fleet: this job may not run on a fleet runner: {why} (.github/fork-fleet.md)') + pid = find() + if pid is None: + say(f'::error::fork-fleet: no {WORKER} above this hook, so the job cannot be stopped: failing the hook') + return 1 + pause(2) # the worker waits for this hook, so nothing of the job runs meanwhile; the log line above gets out + kill(pid, signal.SIGKILL) + return 1 + + +def gate(cfg, env, stop=stop_job): + event, why = read_event(env) + why = why or made_for(env) or refused(cfg, env, event) + if why: + return stop(why) + say(f'fork-fleet: allowed (run {env.get("GITHUB_RUN_ID")} attempt {env.get("GITHUB_RUN_ATTEMPT")}, ' + f'{env.get("GITHUB_EVENT_NAME")} {env.get("GITHUB_REF")})') + return 0 + + +# ---------------------------------------------------------------- GitHub + +class GitHub: + def __init__(self, token, repo): + self.token, self.repo = token, repo + + def call(self, path, method='GET', data=None): + """path: below /repos//, or from the API's root when it starts with /.""" + if not self.token: + raise ValueError(f'no token for {path}') + body = None if data is None else json.dumps(data).encode() + url = f'{API}{path}' if path.startswith('/') else f'{API}/repos/{self.repo}/{path}' + req = urllib.request.Request(url, data=body, method=method, + headers={'Accept': 'application/vnd.github+json', 'User-Agent': 'fork-fleet', + 'Authorization': f'Bearer {self.token}'}) + with urllib.request.urlopen(req, timeout=30) as r: + got = r.read() + return json.loads(got) if got else {} + + def pages(self, path, key, most=10): + out = [] + for page in range(1, most + 1): + got = self.call(f'{path}{"&" if "?" in path else "?"}per_page=100&page={page}')[key] + out += got + if len(got) < 100: + return out + return out + + +# A truncated reply is http.client.IncompleteRead (an HTTPException), a garbled one a ValueError. +TROUBLE = (urllib.error.URLError, http.client.HTTPException, OSError, ValueError, KeyError, TypeError) + + +# ---------------------------------------------------------------- route + +def runs(job, gates): + """Does this job run for this change? gates: the `changes` job's outputs (None when they are missing).""" + if not job.get('gates') or gates is None: + return True + return any(str(gates.get(g)) != 'false' for g in job['gates']) + + +def beacon_free(cfg, pool, env, now): + """(free slots its dispatcher last reported, why none are counted) from the pool's heartbeat variable.""" + raw = env.get(pool['beacon']) or '' + if not raw: + return 0, f'no heartbeat in {pool["beacon"]} (no dispatcher has run)' + try: + got = json.loads(raw) + at, free = got['at'], got['free'] + if isinstance(at, bool) or not isinstance(at, (int, float)) or isinstance(free, bool) or not isinstance(free, int): + raise ValueError(raw) + except (ValueError, TypeError, KeyError): + return 0, f'its heartbeat {pool["beacon"]} cannot be read' + age = now - at + if age > cfg['beacon_fresh_seconds'] or age < -60: + return 0, f'its dispatcher\'s last heartbeat is {int(age)} s old' + note = got.get('why') + if free <= 0 and isinstance(note, str) and note: + return 0, 'its dispatcher says: ' + re.sub(r'[^\w .,:;()/+=-]', '?', note)[:300] + return max(free, 0), None + + +def route(cfg, env, event, mode_value, gates, now=time.time, nonce=None): + """(mode, {key: runs-on}). Raises only for a broken setting or CONFIG; it calls no API (a push: one anonymous + git ls-remote, made_by_the_sync).""" + mode = mode_of(mode_value) + hosted = cfg['hosted'] + out = {key: hosted for key, _ in job_keys(cfg)} + if mode == 'github': + say('GitHub-hosted for every job: the setting is github') + return mode, out + why = refused(cfg, env, event) + if why: + say(f'::notice::fork-fleet: GitHub-hosted for every job: {why} (only trusted runs may use the fleet)') + return mode, out + wanted = [key for key, job in job_keys(cfg) if runs(job, gates)] + if not wanted: + say('GitHub-hosted for every job: no routed job runs for this change') + return mode, out + nonce = nonce or secrets.token_hex(8) # this run's own label: no runner exists for it until the dispatcher makes one + + def labels(pool, key): + return [pool['label'], f'{pool["label"]}-{key}-{nonce}'] + todo, t = list(wanted), now() + for pool in cfg['pools']: + free, why = beacon_free(cfg, pool, env, t) + if why: + # A pool its dispatcher closed (a new actions/runner release: the owner refreshes the template) or reports + # down, or a heartbeat gone stale: a notice on the run page, or auto would go hosted unseen for days. A pool + # with no heartbeat at all was never set up: a log line. + seen = '::notice::fork-fleet: ' if env.get(pool['beacon']) else '' + say(f'{seen}pool {pool["name"]}: no free slot counted: {why}') + for _ in range(free): + if not todo: + break + key = todo.pop(0) + out[key] = labels(pool, key) + say(f'{key}: pool {pool["name"]} (its dispatcher reports a free slot)') + if mode == 'fleet': + waiting = next(p for p in cfg['pools'] if p['waits']) + for key in todo: + out[key] = labels(waiting, key) + say(f'{key}: queues on pool {waiting["name"]} (fleet mode waits for a fleet runner)') + elif todo: + say(f'GitHub-hosted, no free fleet slot left: {", ".join(todo)}') + return mode, out + + +def cmd_route(cfg, env): + event, _ = read_event(env) + gates = None + try: + gates = json.loads(env.get('GATES') or 'null') + except ValueError: + say('::warning::fork-fleet: the changes outputs cannot be read: every routed job counts as running') + mode, out = route(cfg, env, event, env.get('FLEET_MODE'), gates) + attempt = env.get('GITHUB_RUN_ATTEMPT') or '1' + fleet = sum(1 for v in out.values() if not isinstance(v, str)) + lines = [f'runners={json.dumps(out, separators=(",", ":"))}', f'mode={mode}', f'attempt={attempt}', + f'watch={"true" if fleet else "false"}'] + with open(env['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: + f.write(''.join(f'{line}\n' for line in lines)) + where = {} + for key, labels in out.items(): + where.setdefault(labels if isinstance(labels, str) else f'pool label {labels[0]}', []).append(key) + summary(env, [f'### Runners ({mode}, attempt {attempt})', '', '| Runs on | Jobs |', '| --- | --- |'] + + [f'| `{w}` | {", ".join(keys)} |' for w, keys in where.items()]) + return 0 + + +def summary(env, lines): + path = env.get('GITHUB_STEP_SUMMARY') + if path and lines: + with open(path, 'a', encoding='utf-8') as f: + f.write(''.join(f'{line}\n' for line in lines)) + + +# ---------------------------------------------------------------- watch + +def watch(cfg, env, gh, now=time.time, sleep=time.sleep): + """Bounds this run's wait for fleet runners: a fleet job queued stuck_seconds (fleet mode: fleet_stuck_seconds) + cancels the run with an error; in auto the sweep then re-runs it GitHub-hosted.""" + mode = mode_of(env.get('FLEET_MODE')) + bound = cfg['fleet_stuck_seconds'] if mode == 'fleet' else cfg['stuck_seconds'] + rid, attempt = int(env['GITHUB_RUN_ID']), int(env.get('GITHUB_RUN_ATTEMPT') or 1) + try: + want = sum(1 for v in json.loads(env.get('FLEET_RUNNERS') or '{}').values() if not isinstance(v, str)) + except (ValueError, AttributeError): + want = 0 + start, seen = now(), set() + while True: + try: + jobs = [j for j in gh.pages(f'actions/runs/{rid}/attempts/{attempt}/jobs', 'jobs') if is_fleet(cfg, j)] + seen |= {j['id'] for j in jobs} + waiting = [j for j in jobs if j.get('status') == 'queued' and not j.get('runner_name')] + late = [j for j in waiting if now() - (stamp(j.get('created_at')) or now()) >= bound] + if late: + j = late[0] + waited = int((now() - stamp(j['created_at'])) // 60) + say(f'::error::fork-fleet: {j["name"]} waited {waited} min for a fleet runner (labels ' + f'{", ".join(j.get("labels") or [])}): cancelling this run. ' + + ('The sweep re-runs it GitHub-hosted within 10 minutes.' if mode != 'fleet' else + 'Fleet mode: check the pool\'s dispatcher (.github/fork-fleet.md).')) + gh.call(f'actions/runs/{rid}/cancel', method='POST') + return 1 + if not waiting and (len(seen) >= want or now() - start >= SHOW_UP): + say(f'every fleet job of this run has a runner ({len(seen)} of {want})') + return 0 + except TROUBLE as e: + say(f'::warning::fork-fleet: the job list cannot be read ({e.__class__.__name__}: {e}); trying again') + if now() - start >= bound + SHOW_UP: + say('::warning::fork-fleet: stopped watching: GitHub did not answer; in auto the sweep is the fallback') + return 0 + sleep(30) + + +# ---------------------------------------------------------------- sweep + +def rescue_reason(cfg, run, jobs): + """Why a completed run gets one re-run, or None. jobs: its fleet jobs (latest attempt).""" + for j in jobs: + if not j.get('runner_name'): + created, done = stamp(j.get('created_at')), stamp(j.get('completed_at')) + if created and done and done - created >= cfg['stuck_seconds']: + return f'{j["name"]} waited {int((done - created) // 60)} min for a fleet runner and never started' + if run.get('conclusion') == 'cancelled': + return None # cancelled by a person (or a newer push) while a fleet runner had it: that stands + for j in jobs: + if not j.get('runner_name') or j.get('conclusion') not in FAILED: + continue + if not [s for s in j.get('steps') or [] if s.get('conclusion') == 'failure']: + return f'{j["name"]} ended {j["conclusion"]} on {j["runner_name"]} with no failed step of its own' + return None + + +def current(gh, run): + """Is the run's commit still the head of its branch? A newer push makes an old run moot.""" + try: + branch = gh.call(f'branches/{urllib.parse.quote(run.get("head_branch") or "", safe="")}') + except urllib.error.HTTPError as e: + if e.code == 404: + return False # the branch is gone (merged and deleted) + raise + return (branch.get('commit') or {}).get('sha') == run.get('head_sha') + + +def fleet_jobs_of(cfg, gh, run_id): + return [j for j in gh.pages(f'actions/runs/{run_id}/jobs?filter=latest', 'jobs') if is_fleet(cfg, j)] + + +def sweep(cfg, env, gh, now=time.time, sleep=time.sleep): + # Fleet mode re-runs nothing; it only cancels, with an error, a run whose fleet job waited past + # fleet_stuck_seconds (the run's own watch does that too, but a re-run of failed jobs has no watch). + fleet = mode_of(env.get('FLEET_MODE')) == 'fleet' + bound = cfg['fleet_stuck_seconds'] if fleet else cfg['stuck_seconds'] + workflow = os.path.basename(cfg['trusted']['workflows'][0]) + since = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(now() - WINDOW)) + notes, trouble = [], 0 + for run in gh.pages(f'actions/workflows/{workflow}/runs?created=%3E%3D{since}', 'workflow_runs', most=3): + try: + jobs = fleet_jobs_of(cfg, gh, run['id']) + if not jobs: + continue + # The sweep's own re-run is triggered by github-actions: untrusted, so each run is re-run once at most. + why = run_refused(cfg, gh, run) + if why: + say(f'run {run["id"]}: left alone, not a trusted run: {why}') + continue + if run.get('status') != 'completed': + stuck = [j for j in jobs if j.get('status') == 'queued' and not j.get('runner_name') + and now() - stamp(j['created_at']) >= bound] + if not stuck: + continue + waited = f'{stuck[0]["name"]} has waited {int((now() - stamp(stuck[0]["created_at"])) // 60)} min' + if fleet: + say(f'::error::fork-fleet: run {run["id"]}: {waited} for a fleet runner: cancelling the run ' + f'(fleet mode: not re-run; is the fleet\'s dispatcher up? .github/fork-fleet.md)') + gh.call(f'actions/runs/{run["id"]}/cancel', method='POST') + notes.append(f'run {run["id"]} cancelled: {waited} for a fleet runner (fleet mode)') + continue + say(f'run {run["id"]}: {waited} for a fleet runner: cancelling the run, to re-run it GitHub-hosted') + gh.call(f'actions/runs/{run["id"]}/cancel', method='POST') + notes.append(f'run {run["id"]} cancelled: {stuck[0]["name"]} waited for a fleet runner') + end = now() + CANCEL_WAIT + while now() < end: + sleep(10) + run = gh.call(f'actions/runs/{run["id"]}') + if run.get('status') == 'completed': + break + else: + say(f'run {run["id"]}: not finished cancelling yet; the next sweep re-runs it') + continue + jobs = fleet_jobs_of(cfg, gh, run['id']) + why = None if fleet else rescue_reason(cfg, run, jobs) + if not why: + continue + if not current(gh, run): + say(f'run {run["id"]}: {why}, but {run.get("head_branch")!r} has moved on: not re-run') + continue + gh.call(f'actions/runs/{run["id"]}/rerun', method='POST') + say(f'run {run["id"]} re-run (GitHub-hosted: the sweep\'s re-run is not a trusted one): {why}') + notes.append(f'run {run["id"]} re-run: {why}') + except TROUBLE as e: + trouble += 1 + say(f'::warning::fork-fleet: run {run.get("id")}: {e.__class__.__name__}: {e}; the next sweep tries again') + summary(env, notes) + return 1 if trouble else 0 + + +# ---------------------------------------------------------------- dispatch (a fleet host, as root) + +class HostError(Exception): + pass + + +class Host: + """A pool's `launch` command: .github/scripts/fork-fleet-launch.sh, or a wrapper that runs it on another machine.""" + + def __init__(self, pool, run=subprocess.run): + self.pool, self.run = pool, run + self.mine = re.compile(rf'{re.escape(pool["label"])}-[0-9]{{1,20}}') + + def _do(self, *args, stdin=None): + try: + got = self.run(list(self.pool['launch']) + list(args), input=stdin, capture_output=True, text=True, + timeout=120) + except (OSError, subprocess.SubprocessError) as e: + raise HostError(f'pool {self.pool["name"]}: {args[0]}: {e.__class__.__name__}') from None + return got + + def _must(self, *args, stdin=None): + got = self._do(*args, stdin=stdin) + if got.returncode != 0: + last = (got.stderr or '').strip().splitlines()[-1:] or [f'exit {got.returncode}'] + raise HostError(f'pool {self.pool["name"]}: {args[0]}: {last[0]}') + return got.stdout + + def units(self): + """Names of this pool's runners whose unit is still there (starting, running or stopping).""" + return {n for n in self._must('active').split() if self.mine.fullmatch(n)} + + def fits(self, mb): + return self._do('fits', str(int(mb))).returncode == 0 + + def ready(self, newest): + """HostError (with the launcher's reason) unless the pool's runner template is not older than `newest`.""" + self._must('ready', newest) + + def start(self, name, env_text): + self._must('start', name, stdin=env_text) + + def stop(self, name): + self._must('stop', name) + + def prune(self): + self._must('prune') + + +def fleet_job(cfg, labels): + """(pool, job key, attempt) when a job asks for exactly a pool's label and the run's own label for that job.""" + labels = list(labels or ()) + if len(labels) != 2 or labels[0] == labels[1]: + return None + keys = {k for k, _ in job_keys(cfg)} + for pool in cfg['pools']: + if pool['label'] not in labels: + continue + other = labels[1] if labels[0] == pool['label'] else labels[0] + prefix = pool['label'] + '-' + m = NONCE.fullmatch(other[len(prefix):]) if other.startswith(prefix) else None + return (pool, m['key'], int(m['attempt'])) if m and m['key'] in keys else None + return None + + +def could_take(runner_labels, job_labels): + """Could a runner with these labels (plus every default label GitHub may add) be given this job?""" + have = {x.lower() for x in runner_labels} | GENERIC + return {x.lower() for x in job_labels or ()} <= have + + +def runner_env(jit, run): + """The launcher's stdin: KEY=value lines, read into a root-only EnvironmentFile (never argv).""" + if not JIT.fullmatch(jit or ''): + raise ValueError('generate-jitconfig gave no usable config') + return (f'ACTIONS_RUNNER_INPUT_JITCONFIG={jit}\nFORK_FLEET_RUN={int(run["id"])}\n' + f'FORK_FLEET_ATTEMPT={int(run["run_attempt"])}\n') + + +def set_variable(gh, name, value): + try: + gh.call(f'actions/variables/{name}', 'PATCH', {'name': name, 'value': value}) + except urllib.error.HTTPError as e: + if e.code != 404: + raise + gh.call('actions/variables', 'POST', {'name': name, 'value': value}) + + +def new_state(): + """Kept between passes only to pace heartbeats, idle checks, retries, messages and the newest runner release. + GitHub (runs, jobs, runner registrations) and the host (units) hold everything else, so a restarted dispatcher + picks up where one stopped.""" + return {'beacon': {}, 'idle': {}, 'tries': {}, 'said': set(), 'newest': None} + + +RELEASE_EVERY = 3600 # seconds between reads of the newest actions/runner release + + +def newest_runner(gh, state, now): + """The newest actions/runner release (x.y.z), read at most once an hour. A failed read keeps the last one; with + none yet it raises (TROUBLE), so no runner starts before the template has been checked against it.""" + last = state['newest'] + if last and now - last[1] < RELEASE_EVERY: + return last[0] + try: + got = gh.call('/repos/actions/runner/releases/latest') + m = VERSION.fullmatch(str(got.get('tag_name'))) + if not m: + raise ValueError(f'actions/runner\'s latest release is {got.get("tag_name")!r}') + except TROUBLE: + if last: + return last[0] + raise + state['newest'] = ('.'.join(m.groups()), now) + return state['newest'][0] + + +def once(state, key, msg): + if key not in state['said']: + state['said'].add(key) + say(msg) + + +def dispatch_pass(cfg, gh, hosts, state, now=time.time): + """One pass for the pools in `hosts`. GitHub trouble stops the pass. A pool whose host fails (HostError) is left + alone for the rest of the pass, with a warning and a heartbeat of 0 free slots; the other pools go on.""" + pools = [p for p in cfg['pools'] if p['name'] in hosts] + down = {} # pool name: why its host is left alone for the rest of this pass + closed = {} # pool name: why no runner starts on it this pass (its leftovers are still cleaned up) + + def host(p, verb, *args): + """hosts[p].(*args); on a HostError, None and the pool is down for the rest of this pass.""" + try: + return getattr(hosts[p['name']], verb)(*args) + except HostError as e: + down[p['name']] = str(e) + say(f'::warning::fork-fleet: {e}; pool {p["name"]} is left alone for this pass, the other pools go on') + return None + + runs = {} + for status in ('queued', 'in_progress'): + for run in gh.pages(f'actions/runs?status={status}', 'workflow_runs', most=3): + runs[run['id']] = run + queued = [(run, j) for run in runs.values() for j in gh.pages(f'actions/runs/{run["id"]}/jobs?filter=latest', 'jobs') + if j.get('status') == 'queued' and not j.get('runner_name')] + queued_ids = {j['id'] for _, j in queued} + state['said'] = {k for k in state['said'] if k[0] in queued_ids or k[0] == 'ready'} + state['tries'] = {k: v for k, v in state['tries'].items() if k in queued_ids} + newest = newest_runner(gh, state, now()) + units = {} + for p in pools: + got = host(p, 'units') + if got is None: + continue + units[p['name']] = got + try: + hosts[p['name']].ready(newest) + except HostError as e: + closed[p['name']] = str(e) + once(state, ('ready', p['name'], str(e)), f'::warning::fork-fleet: {e}') + ours = {} + for r in gh.pages('actions/runners', 'runners'): + for p in pools: + if hosts[p['name']].mine.fullmatch(r.get('name') or ''): + ours[r['name']] = (p, r) + state['idle'] = {k: v for k, v in state['idle'].items() if k in ours} + + # Leftovers: a registration with no runner behind it, and a runner whose job went away before it started. Such a + # runner is stopped at once when a queued job of another run could be given it (the hook would refuse that job), + # else after idle_passes passes (a runner that is just taking its own job is not busy yet). A runner's labels name + # the run attempt it was made for, so no job of another attempt can be given it. + for name, (p, r) in ours.items(): + if p['name'] in down: + continue + if r.get('status') == 'offline' and name not in units[p['name']]: + try: + gh.call(f'actions/runners/{int(r["id"])}', 'DELETE') + except urllib.error.HTTPError as e: + if e.code != 404: + raise + say(f'{name}: deleted a registration with no runner behind it') + elif r.get('status') == 'online' and not r.get('busy') and int(name.rsplit('-', 1)[1]) not in queued_ids: + state['idle'][name] = state['idle'].get(name, 0) + 1 + labels = [x.get('name') for x in r.get('labels') or [] if isinstance(x, dict)] + if state['idle'][name] >= cfg['dispatch']['idle_passes'] or \ + any(could_take(labels, j.get('labels')) for _, j in queued): + host(p, 'stop', name) + if p['name'] in down: + continue + state['idle'].pop(name) + say(f'{name}: stopped, its job is no longer queued') + else: + state['idle'].pop(name, None) + for p in pools: + if p['name'] not in down: + host(p, 'prune') + + # Launches: one runner per queued fleet job of a trusted run. + active = {name: len(got) for name, got in units.items()} + pending = {p['name']: 0 for p in pools} + started_mb = {p['name']: 0 for p in pools} # memory of runners this pass started, not in use yet + memory = {k: j['memory_mb'] for k, j in job_keys(cfg)} + trust = {} + for run, j in queued: + hit = fleet_job(cfg, j.get('labels')) + if not hit or hit[0]['name'] not in hosts: + continue + pool, key, attempt = hit + if j.get('run_attempt', run.get('run_attempt')) != run.get('run_attempt'): + continue # re-run after the run list was read: its runner would be made for the old attempt; next pass + if attempt != run.get('run_attempt'): + continue # its label names another attempt: the gate would refuse it on any runner + name = f'{pool["label"]}-{int(j["id"])}' + if pool['name'] in down or name in units[pool['name']] or name in ours: + continue # its host failed; or started already (its runner is coming up), or a leftover deleted above + tag = f'job {j["id"]} ({j.get("name")}) of run {run["id"]}' + if (run['id'], run.get('run_attempt')) not in trust: + trust[(run['id'], run.get('run_attempt'))] = run_refused(cfg, gh, run) + why = trust[(run['id'], run.get('run_attempt'))] + if why: + once(state, (j['id'], 'trust'), f'{tag}: no runner, not a trusted run: {why}') + continue + pending[pool['name']] += 1 + if pool['name'] in closed: + continue + if state['tries'].get(j['id'], 0) >= MAX_TRIES: + once(state, (j['id'], 'tries'), f'::warning::{tag}: {MAX_TRIES} runners started and none took it; left to ' + 'the watch and the sweep') + continue + if active[pool['name']] >= pool['slots']: + continue + rivals = [o for _, o in queued if o['id'] != j['id'] and could_take(j['labels'], o.get('labels'))] + if rivals: + once(state, (j['id'], 'rival', rivals[0]['id']), + f'::warning::{tag}: not started while job {rivals[0]["id"]} ({rivals[0].get("name")}) of another run ' + f'could take its runner (labels {rivals[0].get("labels")})') + continue + fits = host(pool, 'fits', memory[key] + started_mb[pool['name']]) + if pool['name'] in down: + continue + if not fits: + once(state, (j['id'], 'memory'), f'{tag}: waits for {memory[key]} MB on pool {pool["name"]}') + continue + try: + got = gh.call('actions/runners/generate-jitconfig', 'POST', + {'name': name, 'runner_group_id': RUNNER_GROUP, 'labels': list(j['labels']), + 'work_folder': '_work'}) + except urllib.error.HTTPError as e: + if e.code != 409: + raise + continue # a registration with this name appeared since the list: the next pass sorts it out + state['tries'][j['id']] = state['tries'].get(j['id'], 0) + 1 + host(pool, 'start', name, runner_env(got.get('encoded_jit_config'), run)) + if pool['name'] in down: + continue # the next pass deletes its registration (no runner behind it) and tries again + active[pool['name']] += 1 + pending[pool['name']] -= 1 + started_mb[pool['name']] += memory[key] + say(f'{name}: started for {tag}, attempt {run.get("run_attempt")}') + + # Heartbeats: what `route` may count on, per pool, with the reason when it is nothing. + t = now() + biggest = max(memory.values()) + for p in pools: + why = down.get(p['name']) or closed.get(p['name']) + free = 0 if why else max(p['slots'] - active[p['name']] - pending[p['name']], 0) + if free and not host(p, 'fits', biggest + started_mb[p['name']]): + free, why = 0, down.get(p['name']) + beat = {'at': int(t), 'free': free} + if why: + beat['why'] = why[:300] + last = state['beacon'].get(p['name']) + if last and last[0] == (free, why) and t - last[1] < cfg['dispatch']['beacon_seconds']: + continue + set_variable(gh, p['beacon'], json.dumps(beat, separators=(',', ':'))) + state['beacon'][p['name']] = ((free, why), t) + return 0 + + +def read_token(path): + st = os.stat(path) + if st.st_uid != os.geteuid() or st.st_mode & 0o077: + raise ValueError(f'{path} must belong to this user and be readable by it alone (chmod 600)') + with open(path, encoding='utf-8') as f: + token = f.read().strip() + if not token: + raise ValueError(f'{path} is empty') + return token + + +def take_lock(path): + import fcntl + os.makedirs(os.path.dirname(path) or '.', mode=0o700, exist_ok=True) # /run is empty after a boot + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(fd) + raise ValueError(f'another dispatcher holds {path}') from None + return fd + + +def take_locks(lock_dir, names): + """One lock per pool served: a second dispatcher may serve the other pools, never one this one serves.""" + fds = [] + try: + for name in sorted(set(names)): + fds.append(take_lock(os.path.join(lock_dir, f'dispatch-{name}.lock'))) + except ValueError: + for fd in fds: + os.close(fd) + raise + return fds + + +def cmd_dispatch(cfg, args, sleep=time.sleep): + names = args.pool or [p['name'] for p in cfg['pools']] + unknown = sorted(set(names) - {p['name'] for p in cfg['pools']}) + if unknown: + raise ValueError(f'no pool {unknown} in the config') + token = read_token(args.token_file) + locks = take_locks(args.lock_dir, names) + gh = GitHub(token, cfg['repository']) + hosts = {p['name']: Host(p) for p in cfg['pools'] if p['name'] in names} + state = new_state() + while True: + try: + dispatch_pass(cfg, gh, hosts, state) + except (*TROUBLE, HostError) as e: + say(f'::warning::fork-fleet: this pass stopped ({e.__class__.__name__}: {e}); the next one starts over') + if args.once: + return 1 + if args.once: + for fd in locks: + os.close(fd) + return 0 + sleep(cfg['dispatch']['every_seconds']) + + +# ---------------------------------------------------------------- main + +def main(argv=None, env=None): + env = os.environ if env is None else env + ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + ap.add_argument('--config', default=CONFIG) + sub = ap.add_subparsers(dest='cmd', required=True) + sub.add_parser('route') + sub.add_parser('gate') + sub.add_parser('watch') + sub.add_parser('sweep') + dis = sub.add_parser('dispatch', allow_abbrev=False) # --lock (one lock for all pools) is gone: no prefix match + dis.add_argument('--pool', action='append', help='a pool this host serves (default: every pool)') + dis.add_argument('--token-file', default='/etc/fork-fleet/token') + dis.add_argument('--lock-dir', default='/run/fork-fleet', help='where dispatch-.lock is taken, one per pool') + dis.add_argument('--once', action='store_true', help='one pass, then exit (1 when it stopped half-way)') + args = ap.parse_args(argv) + cfg = load(args.config) + if args.cmd == 'gate': + return gate(cfg, env) + if args.cmd == 'route': + return cmd_route(cfg, env) + if args.cmd == 'dispatch': + return cmd_dispatch(cfg, args) + gh = GitHub(env.get('GITHUB_TOKEN'), cfg['repository']) + return watch(cfg, env, gh) if args.cmd == 'watch' else sweep(cfg, env, gh) + + +if __name__ == '__main__': + gating = 'gate' in sys.argv[1:] + try: + sys.exit(main()) + except Exception as e: # the gate refuses on anything unexpected: a gate that crashes open is no gate + if gating: + sys.exit(stop_job(f'the gate itself failed: {e.__class__.__name__}: {e}')) + say(f'::error::fork-fleet: {e.__class__.__name__}: {e}') + sys.exit(1) diff --git a/.github/scripts/fork-fleet.test.py b/.github/scripts/fork-fleet.test.py new file mode 100644 index 0000000000000..d4fce4eff777e --- /dev/null +++ b/.github/scripts/fork-fleet.test.py @@ -0,0 +1,2383 @@ +#!/usr/bin/env python3 +"""Tests of fork-fleet.py, fork-fleet-launch.sh and fork-fleet-hook.sh, and of their wiring in fork-tests.yml, +fork-fleet.yml and fork-release.yml. Run: python3 . Linux only (the gate and launcher tests run real processes).""" +import copy, http.client, importlib.util, io, json, os, pwd, re, shutil, signal, stat, subprocess, sys, tempfile +import unittest, urllib.error, urllib.parse +from contextlib import redirect_stdout +from functools import partial + +sys.dont_write_bytecode = True # importing fork-fleet.py must not leave __pycache__ in the tree + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +LAUNCH = os.path.join(HERE, 'fork-fleet-launch.sh') +_spec = importlib.util.spec_from_file_location('fork_fleet', os.path.join(HERE, 'fork-fleet.py')) +ff = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(ff) + +CFG = ff.load() +REPO = CFG['repository'] +OWNER = CFG['trusted']['actors'][0] +WORKFLOW = CFG['trusted']['workflows'][0] +POOLS = {p['name']: p for p in CFG['pools']} +FIRST, SECOND = CFG['pools'][0]['name'], CFG['pools'][1]['name'] # route fills pools in this order +WAITING = next(p for p in CFG['pools'] if p['waits'])['name'] +KEYS = [k for k, _ in ff.job_keys(CFG)] +MEMORY = {k: j['memory_mb'] for k, j in ff.job_keys(CFG)} +HOSTED = CFG['hosted'] +NONCE = '0123456789abcdef' +NEWEST_RUNNER = '2.330.0' +T0 = 1_800_000_000 +SHA = '0123456789abcdef0123456789abcdef01234567' # a pushed commit +SYNCED = 'feedface' * 5 # a commit the upstream sync tagged +UNKNOWN = 'decafbad' * 5 # a commit whose tag cannot be read (git fails) +SYNC_TAG = CFG['upstream_sync']['tag_prefix'] +GIT_ENV = {'PATH': '/usr/bin:/bin', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1', 'LC_ALL': 'C', + 'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@t', 'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@t'} + + +def fake_ls_remote(argv, **kw): + """`git ls-remote --exit-code ` of this repository: SYNCED's tag exists, UNKNOWN's read fails.""" + code = 0 if argv[-1].endswith(SYNCED) else 128 if argv[-1].endswith(UNKNOWN) else 2 + return subprocess.CompletedProcess(argv, code, '', '') + + +REAL_MADE_BY_THE_SYNC = ff.made_by_the_sync + + +def made_by_the_sync_offline(cfg, sha, run=fake_ls_remote): + return REAL_MADE_BY_THE_SYNC(cfg, sha, run=run) + + +ff.made_by_the_sync = made_by_the_sync_offline # every test but UpstreamSync and GateProcess: no network + + +def sync_remote(top): + """(SYNC_REMOTE of a local bare copy of this repository, a commit the sync tagged there) for the real git.""" + def git(*a): + return subprocess.run(['git', *a], env=GIT_ENV, check=True, capture_output=True, text=True).stdout.strip() + remote = os.path.join(top, 'remote', '{}.git') + work = os.path.join(top, 'work') + git('init', '-q', '--bare', remote.format(REPO)) + git('init', '-q', work) + git('-C', work, 'commit', '-q', '--allow-empty', '-m', 'upstream') + sha = git('-C', work, 'rev-parse', 'HEAD') + git('-C', work, 'push', '-q', remote.format(REPO), f'HEAD:refs/tags/{SYNC_TAG}{sha}') + return 'file://' + remote, sha + + +def env(**over): + e = {'GITHUB_REPOSITORY': REPO, 'GITHUB_WORKFLOW_REF': f'{REPO}/{WORKFLOW}@refs/heads/main', + 'GITHUB_ACTOR': OWNER, 'GITHUB_TRIGGERING_ACTOR': OWNER, 'GITHUB_EVENT_NAME': 'push', + 'GITHUB_REF': 'refs/heads/main', 'GITHUB_SHA': SHA, 'GITHUB_RUN_ID': '5', 'GITHUB_RUN_ATTEMPT': '1'} + e.update(over) + return {k: v for k, v in e.items() if v is not None} + + +PUSH = {'repository': {'full_name': REPO}} +PR_ENV = dict(GITHUB_EVENT_NAME='pull_request', GITHUB_REF='refs/pull/7/merge') + + +def pr(head=REPO, base=REPO, user=OWNER): + return {'pull_request': {'head': {'repo': {'full_name': head}}, 'base': {'repo': {'full_name': base}}, + 'user': {'login': user}}} + + +def labels(pool, key, nonce=NONCE): + """What `route` puts out for a fleet job.""" + return [POOLS[pool]['label'], f'{POOLS[pool]["label"]}-{key}-{nonce}'] + + +def job_labels(pool, key, nonce=NONCE, attempt=1): + """The labels a fleet job of that run attempt asks for: runs-on adds the attempt to route's run label.""" + pool_label, run_label = labels(pool, key, nonce) + return [pool_label, f'{run_label}-a{attempt}'] + + +def quiet(fn, *a, **k): + out = io.StringIO() + with redirect_stdout(out): + got = fn(*a, **k) + return got, out.getvalue() + + +def iso(t): + return ff.time.strftime('%Y-%m-%dT%H:%M:%SZ', ff.time.gmtime(t)) + + +def http_error(code): + return urllib.error.HTTPError('https://api.github.test/x', code, 'no', None, None) + + +def a_run(rid=5, *, actor=OWNER, triggering=None, event='push', branch='main', attempt=1, status='queued', + conclusion=None, prs=(), head_repo=REPO, repo=REPO, path=WORKFLOW, sha=SHA): + """A workflow run as GitHub's API records it.""" + return {'id': rid, 'run_attempt': attempt, 'status': status, 'conclusion': conclusion, 'event': event, + 'head_branch': branch, 'head_sha': sha, 'path': path, 'repository': {'full_name': repo}, + 'head_repository': {'full_name': head_repo}, 'actor': {'login': actor}, + 'triggering_actor': {'login': triggering or actor}, 'pull_requests': [{'number': n} for n in prs]} + + +def a_job(jid, job_labels, *, status='queued', runner=None, conclusion=None, created=T0, completed=None, steps=(), + name=None): + return {'id': jid, 'name': name or f'job {jid}', 'labels': list(job_labels), 'status': status, + 'runner_name': runner, 'conclusion': conclusion, 'created_at': iso(created), + 'completed_at': iso(completed) if completed else None, 'steps': list(steps)} + + +class FakeGitHub: + """This repository as GitHub's API shows it: runs and their jobs, runners, pull requests, branches, variables. + Records every call; `trouble` is [(regex of 'METHOD path', exception)].""" + + def __init__(self): + self.runs, self.jobs, self.pulls, self.branches, self.variables = {}, {}, {}, {}, {} + self.runners, self.calls, self.trouble = [], [], [] + self.on_cancel = None + self.newest = NEWEST_RUNNER + self.ids = iter(range(900, 10_000)) + + def add(self, run, *jobs): + self.runs[run['id']] = run + self.jobs[run['id']] = list(jobs) + return run + + def own_pr(self, number=7, head=REPO, base=REPO, user=OWNER): + self.pulls[number] = {'number': number, 'head': {'repo': {'full_name': head}}, + 'base': {'repo': {'full_name': base}}, 'user': {'login': user}} + + def pages(self, path, key, most=10): + return self.call(path)[key] + + def writes(self): + return [(m, p) for m, p, _ in self.calls if m != 'GET'] + + def call(self, path, method='GET', data=None): + self.calls.append((method, path, data)) + route = f'{method} {path}' + for match, error in self.trouble: + if re.fullmatch(match, route): + raise error + if m := re.fullmatch(r'GET actions/runs\?status=(\w+)', route): + return {'workflow_runs': [r for r in self.runs.values() if r['status'] == m[1]]} + if re.fullmatch(r'GET actions/workflows/[\w.-]+/runs\?created=%3E%3D[\dT:Z-]+', route): + return {'workflow_runs': sorted(self.runs.values(), key=lambda r: -r['id'])} + if m := re.fullmatch(r'GET actions/runs/(\d+)/(?:jobs\?filter=latest|attempts/\d+/jobs)', route): + return {'jobs': copy.deepcopy(self.jobs[int(m[1])])} + if m := re.fullmatch(r'GET actions/runs/(\d+)', route): + return copy.deepcopy(self.runs[int(m[1])]) + if m := re.fullmatch(r'POST actions/runs/(\d+)/(cancel|rerun)', route): + if m[2] == 'cancel' and self.on_cancel: + self.on_cancel(int(m[1])) + return {} + if m := re.fullmatch(r'GET branches/(.+)', route): + name = urllib.parse.unquote(m[1]) + if name not in self.branches: + raise http_error(404) + return {'name': name, 'commit': {'sha': self.branches[name]}} + if m := re.fullmatch(r'GET pulls/(\d+)', route): + return copy.deepcopy(self.pulls[int(m[1])]) + if route == 'GET /repos/actions/runner/releases/latest': + return {'tag_name': f'v{self.newest}'} + if route == 'GET actions/runners': + return {'runners': copy.deepcopy(self.runners)} + if route == 'POST actions/runners/generate-jitconfig': + if any(r['name'] == data['name'] for r in self.runners): + raise http_error(409) + rid = next(self.ids) + self.runners.append({'id': rid, 'name': data['name'], 'status': 'offline', 'busy': False, + 'labels': [{'name': x} for x in ['self-hosted', 'Linux', 'X64'] + data['labels']]}) + return {'runner': {'id': rid, 'name': data['name']}, 'encoded_jit_config': f'SklUc2VjcmV0{rid}=='} + if m := re.fullmatch(r'DELETE actions/runners/(\d+)', route): + if not any(r['id'] == int(m[1]) for r in self.runners): + raise http_error(404) + self.runners = [r for r in self.runners if r['id'] != int(m[1])] + return {} + if m := re.fullmatch(r'PATCH actions/variables/(\w+)', route): + if m[1] not in self.variables: + raise http_error(404) + self.variables[m[1]] = data['value'] + return {} + if route == 'POST actions/variables': + if data['name'] in self.variables: + raise http_error(409) + self.variables[data['name']] = data['value'] + return {} + raise AssertionError(f'unexpected call {route}') + + +class Config(unittest.TestCase): + def test_the_repository_config_is_valid(self): + self.assertEqual(ff.check_config(CFG), []) + + def test_broken_configs_are_refused(self): + cases = { + 'a generic label': (lambda c: c['pools'][0].__setitem__('label', 'self-hosted'), 'label'), + 'two pools, one label': (lambda c: c['pools'][1].__setitem__('label', c['pools'][0]['label']), 'label'), + 'a beacon GitHub owns': (lambda c: c['pools'][0].__setitem__('beacon', 'GITHUB_X'), 'beacon'), + 'no launch command': (lambda c: c['pools'][0].__setitem__('launch', []), 'launch'), + 'no waiting pool': (lambda c: [p.__setitem__('waits', False) for p in c['pools']], 'waits'), + 'two waiting pools': (lambda c: [p.__setitem__('waits', True) for p in c['pools']], 'waits'), + 'no slots': (lambda c: c['pools'][0].__setitem__('slots', 0), 'slots'), + 'a job id with a dash': (lambda c: c['jobs'][0].__setitem__('job', 'a-b'), 'id'), + 'a dispatcher pace of 0': (lambda c: c['dispatch'].__setitem__('every_seconds', 0), 'every_seconds'), + 'no stuck bound': (lambda c: c.pop('stuck_seconds'), 'stuck_seconds'), + 'no upstream sync tag prefix': (lambda c: c.pop('upstream_sync'), 'upstream_sync'), + 'a sync tag prefix without its /': (lambda c: c['upstream_sync'].__setitem__('tag_prefix', 'synced'), + 'tag_prefix'), + } + for name, (change, word) in cases.items(): + with self.subTest(name): + bad = copy.deepcopy(CFG) + change(bad) + problems = ff.check_config(bad) + self.assertTrue(any(word in p for p in problems), problems) + + def test_shards_expand_to_keys(self): + self.assertIn('test_server-8', KEYS) + self.assertNotIn('test_server', KEYS) + self.assertIn('lint', KEYS) + + def test_the_setting(self): + self.assertEqual(ff.mode_of(None), 'auto') + self.assertEqual(ff.mode_of(''), 'auto') + self.assertEqual(ff.mode_of(' Fleet '), 'fleet') + with self.assertRaises(ValueError): + ff.mode_of('fleeet') # a typo must not quietly mean auto + + +class Rule(unittest.TestCase): + def test_push_to_main_by_the_owner(self): + self.assertIsNone(ff.refused(CFG, env(), PUSH)) + + def test_own_pull_request(self): + self.assertIsNone(ff.refused(CFG, env(**PR_ENV), pr())) + + def test_refused(self): + cases = { + 'pull request from a fork': (env(**PR_ENV), pr(head='someone/t3code')), + 'pull request into another repo': (env(**PR_ENV), pr(base='pingdotgg/t3code')), + 'pull request opened by someone else': (env(**PR_ENV), pr(user='dependabot[bot]')), + 'actor': (env(GITHUB_ACTOR='someone'), PUSH), + 'triggering actor (a re-run by someone else)': (env(GITHUB_TRIGGERING_ACTOR='someone'), PUSH), + 'push to another branch': (env(GITHUB_REF='refs/heads/fork/x'), PUSH), + 'push to a tag': (env(GITHUB_REF='refs/tags/v1'), PUSH), + 'another repository': (env(GITHUB_REPOSITORY='someone/t3code'), PUSH), + 'a push payload of another repository': (env(), {'repository': {'full_name': 'someone/t3code'}}), + 'another workflow': (env(GITHUB_WORKFLOW_REF=f'{REPO}/.github/workflows/ci.yml@refs/heads/main'), PUSH), + 'a workflow whose path only starts the same': ( + env(GITHUB_WORKFLOW_REF=f'{REPO}/{WORKFLOW}x@refs/heads/main'), PUSH), + 'workflow_dispatch (release runs)': (env(GITHUB_EVENT_NAME='workflow_dispatch'), {}), + 'schedule': (env(GITHUB_EVENT_NAME='schedule'), {}), + 'pull_request_target': (env(GITHUB_EVENT_NAME='pull_request_target'), pr()), + 'no payload': (env(), None), + 'no variables': ({}, PUSH), + 'a push the upstream sync made (its tag exists)': (env(GITHUB_SHA=SYNCED), PUSH), + 'a push whose sync tag cannot be read': (env(GITHUB_SHA=UNKNOWN), PUSH), + 'a push with no commit sha': (env(GITHUB_SHA=None), PUSH), + 'a push with a short sha': (env(GITHUB_SHA=SHA[:12]), PUSH), + } + for name, (e, event) in cases.items(): + with self.subTest(name): + self.assertIsNotNone(ff.refused(CFG, e, event)) + + +class RunRefused(unittest.TestCase): + """The rule on GitHub's own record of a run: what the dispatcher and the sweep decide by.""" + + def check(self, run, gh=None): + gh = gh or FakeGitHub() + return ff.run_refused(CFG, gh, run), gh + + def test_a_push_to_main_by_the_owner(self): + self.assertIsNone(self.check(a_run())[0]) + + def test_the_owners_own_pull_request_is_read_from_the_api(self): + gh = FakeGitHub() + gh.own_pr() + why, gh = self.check(a_run(event='pull_request', branch='fork/x', prs=[7]), gh) + self.assertIsNone(why) + self.assertIn(('GET', 'pulls/7', None), gh.calls) + + def test_refused(self): + def stranger_pr(gh): + gh.own_pr(user='someone') + + def other_head(gh): + gh.own_pr(head='someone/t3code') + cases = { + 'a pull request run listing no pull request (one from a fork)': + (a_run(event='pull_request', branch='x', head_repo='someone/t3code'), None), + 'a pull request whose head is not the run\'s': (a_run(event='pull_request', branch='x', prs=[7]), other_head), + 'a pull request someone else opened': (a_run(event='pull_request', branch='x', prs=[7]), stranger_pr), + 'a re-run by github-actions (the sweep\'s own)': (a_run(attempt=2, triggering='github-actions[bot]'), None), + 'a re-run by someone else': (a_run(attempt=2, triggering='someone'), None), + 'a stranger\'s push': (a_run(actor='someone'), None), + 'a push to another branch': (a_run(branch='fork/x'), None), + 'another workflow': (a_run(path='.github/workflows/ci.yml'), None), + 'another repository': (a_run(repo='someone/t3code'), None), + 'a workflow_dispatch run': (a_run(event='workflow_dispatch'), None), + 'a workflow_run run': (a_run(event='workflow_run'), None), + 'the push run of a commit the upstream sync made': (a_run(sha=SYNCED), None), + 'a push run whose sync tag cannot be read': (a_run(sha=UNKNOWN), None), + } + for name, (run, setup) in cases.items(): + with self.subTest(name): + gh = FakeGitHub() + if setup: + setup(gh) + self.assertIsNotNone(self.check(run, gh)[0]) + + +class Gate(unittest.TestCase): + """The hook's decision (the kill itself: GateProcess).""" + + def gate(self, e, event=PUSH): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, 'event.json') + if event is not None: + with open(path, 'w') as f: + f.write(event if isinstance(event, str) else json.dumps(event)) + stops = [] + got, log = quiet(ff.gate, CFG, dict(e, GITHUB_EVENT_PATH=path), stop=lambda why: stops.append(why) or 1) + return got, stops + + MADE = dict(FORK_FLEET_RUN='5', FORK_FLEET_ATTEMPT='1') + + def test_the_job_its_runner_was_made_for(self): + self.assertEqual(self.gate(env(**self.MADE)), (0, [])) + self.assertEqual(self.gate(env(**PR_ENV, **self.MADE), pr()), (0, [])) + + def test_refused_and_stopped(self): + cases = { + 'a runner the dispatcher did not start': env(), + 'another run': env(GITHUB_RUN_ID='6', **self.MADE), + 'a re-run on a runner made for attempt 1': env(GITHUB_RUN_ATTEMPT='2', **self.MADE), + 'a stranger\'s run': env(GITHUB_ACTOR='someone', **self.MADE), + 'a push the upstream sync made': env(GITHUB_SHA=SYNCED, **self.MADE), + } + for name, e in cases.items(): + with self.subTest(name): + got, stops = self.gate(e) + self.assertEqual((got, len(stops)), (1, 1)) + for name, event in {'a fork\'s pull request': pr(head='someone/t3code'), 'no event file': None, + 'a broken event file': '{'}.items(): + with self.subTest(name): + got, stops = self.gate(env(**PR_ENV, **self.MADE), event) + self.assertEqual((got, len(stops)), (1, 1)) + + def test_made_for(self): + self.assertIsNone(ff.made_for(env(**self.MADE))) + self.assertIn('not started by the dispatcher', ff.made_for(env())) + self.assertIn('attempt 1, not run 5 attempt 2', ff.made_for(env(GITHUB_RUN_ATTEMPT='2', **self.MADE))) + + def test_stop_job_kills_the_worker(self): + kills = [] + got, log = quiet(ff.stop_job, 'why', kill=lambda pid, sig: kills.append((pid, sig)), find=lambda: 4321, + pause=lambda s: None) + self.assertEqual((got, kills), (1, [(4321, signal.SIGKILL)])) + self.assertIn('::error::', log) + kills.clear() + got, log = quiet(ff.stop_job, 'why', kill=lambda pid, sig: kills.append(pid), find=lambda: None, + pause=lambda s: None) + self.assertEqual((got, kills), (1, [])) + self.assertIn('cannot be stopped', log) + + +class WorkerAbove(unittest.TestCase): + def proc(self, d, table): + for pid, (comm, ppid) in table.items(): + os.makedirs(os.path.join(d, str(pid))) + with open(os.path.join(d, str(pid), 'stat'), 'w') as f: + f.write(f'{pid} ({comm}) S {ppid} {pid} {pid} 0 -1 4194560 0 0\n') + + def test_the_nearest_worker_among_the_ancestors(self): + with tempfile.TemporaryDirectory() as d: + self.proc(d, {40: ('python3', 30), 30: ('a) S 7 (b', 20), 20: ('Runner.Worker', 15), + 15: ('Runner.Worker', 10), 10: ('Runner.Listener', 1)}) + self.assertEqual(ff.worker_above(40, proc=d), 20) + + def test_none_without_a_worker(self): + with tempfile.TemporaryDirectory() as d: + self.proc(d, {40: ('python3', 30), 30: ('bash', 1), 50: ('loop', 50), 60: ('x', 61)}) + self.assertIsNone(ff.worker_above(40, proc=d)) + self.assertIsNone(ff.worker_above(50, proc=d)) # a loop ends + self.assertIsNone(ff.worker_above(60, proc=d)) # a gone parent ends + + +@unittest.skipUnless(os.path.isfile('/usr/bin/python3') and os.path.isdir('/proc/self'), 'Linux with /usr/bin/python3') +class GateProcess(unittest.TestCase): + """The installed hook as a runner runs it: a process named Runner.Worker runs the job-started hook, then, whatever + the hook returned, the job's `if: always()` step. A refused job must end with the hook: no marker.""" + + WORKER = ('import subprocess, sys\n' + 'hook, marker = sys.argv[1], sys.argv[2]\n' + 'subprocess.run(["/bin/bash", hook]) # the job-started hook\n' + 'with open(marker, "w") as f: # an `if: always()` step\n' + ' f.write("ran")\n') + + def install(self, d, config=None, mutate=None, remote=None): + """remote: SYNC_REMOTE of the copy (a local bare repository, sync_remote), so its sync check reads no network.""" + opt = os.path.join(d, 'opt') + os.mkdir(opt) + shutil.copy(os.path.join(HERE, 'fork-fleet.py'), opt) + shutil.copy(os.path.join(HERE, 'fork-fleet-hook.sh'), os.path.join(opt, 'hook.sh')) + with open(os.path.join(opt, 'fork-fleet.json'), 'w') as f: + f.write(json.dumps(CFG) if config is None else config) + changes = ([mutate] if mutate else []) + ([(f'SYNC_REMOTE = {ff.SYNC_REMOTE!r}', f'SYNC_REMOTE = {remote!r}')] + if remote else []) + path = os.path.join(opt, 'fork-fleet.py') + for old, new in changes: + with open(path) as f: + text = f.read() + self.assertEqual(text.count(old), 1) + with open(path, 'w') as f: + f.write(text.replace(old, new)) + return os.path.join(opt, 'hook.sh') + + def start(self, d, hook, e, event): + worker = os.path.join(d, 'Runner.Worker') + os.symlink(os.path.realpath(sys.executable), worker) + script, marker, event_path = (os.path.join(d, n) for n in ('worker.py', 'marker', 'event.json')) + with open(script, 'w') as f: + f.write(self.WORKER) + with open(event_path, 'w') as f: + json.dump(event, f) + e = dict(e, PATH='/usr/bin:/bin', GITHUB_EVENT_PATH=event_path) + return subprocess.Popen([worker, '-I', script, hook, marker], env=e, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True), marker + + def test_the_hook_ends_a_refused_job_before_its_always_steps(self): + made = dict(FORK_FLEET_RUN='5', FORK_FLEET_ATTEMPT='1') + with tempfile.TemporaryDirectory() as top: + remote, synced = sync_remote(top) + self.check_hook(made, remote, synced) + + def check_hook(self, made, remote, synced): + cases = { # name: (environment, event, config, mutation, the always() step runs) + 'allowed': (env(**made), PUSH, None, None, True), + 'a push the upstream sync made (real git, its tag in the repository)': + (env(GITHUB_SHA=synced, **made), PUSH, None, None, False), + 'a push whose sync tag cannot be read (git fails)': + (env(**made), PUSH, None, ("'ls-remote', '--exit-code'", "'no-such-command', '--exit-code'"), False), + 'a fork\'s pull request': (env(**PR_ENV, **made), pr(head='someone/t3code'), None, None, False), + 'a re-run on an old runner': (env(GITHUB_RUN_ATTEMPT='2', **made), PUSH, None, None, False), + 'a runner the dispatcher did not start': (env(), PUSH, None, None, False), + 'a gate that crashes (broken config)': (env(**made), PUSH, '[]', None, False), + # The test's own check: without the kill, the always() step does run. + 'a mutant gate that only exits 1': (env(**PR_ENV, **made), pr(head='someone/t3code'), None, + ('kill(pid, signal.SIGKILL)', 'pid'), True), + } + with tempfile.TemporaryDirectory() as top: + started = {} + for i, (name, (e, event, config, mutate, runs)) in enumerate(cases.items()): + d = os.path.join(top, str(i)) + os.mkdir(d) + started[name] = self.start(d, self.install(d, config, mutate, remote), e, event), runs + for name, ((proc, marker), runs) in started.items(): + with self.subTest(name): + out = proc.communicate(timeout=60)[0] + self.assertEqual(os.path.exists(marker), runs, out) + if runs: + self.assertEqual(proc.returncode, 0, out) + else: + self.assertEqual(proc.returncode, -signal.SIGKILL, out) + self.assertIn('::error::fork-fleet: this job may not run on a fleet runner', out) + + def test_the_crash_message(self): + with tempfile.TemporaryDirectory() as d: + (proc, marker) = self.start(d, self.install(d, '[]'), env(FORK_FLEET_RUN='5', FORK_FLEET_ATTEMPT='1'), + PUSH) + out = proc.communicate(timeout=60)[0] + self.assertIn('the gate itself failed', out) + self.assertFalse(os.path.exists(marker)) + + +class UpstreamSync(unittest.TestCase): + """made_by_the_sync itself, with the real git: the push run of a commit the upstream sync tagged never uses the + fleet (owner decision: upstream code runs on GitHub-hosted runners only); any doubt refuses.""" + + def call(self, sha, code=2, error=None): + calls = [] + + def run(argv, **kw): + calls.append((argv, kw)) + if error: + raise error + return subprocess.CompletedProcess(argv, code, '', '') + return REAL_MADE_BY_THE_SYNC(CFG, sha, run=run), calls + + def test_it_asks_this_repository_anonymously_for_the_one_tag(self): + why, calls = self.call(SHA) + self.assertIsNone(why) + (argv, kw), = calls + self.assertEqual(argv, ['git', '-c', 'protocol.version=2', 'ls-remote', '--exit-code', + f'https://github.com/{REPO}.git', f'refs/tags/{SYNC_TAG}{SHA}']) + self.assertEqual(kw['env']['GIT_CONFIG_GLOBAL'], '/dev/null') # no config, helper or token of the caller + self.assertEqual(kw['env']['GIT_CONFIG_NOSYSTEM'], '1') + self.assertEqual(kw['env']['GIT_TERMINAL_PROMPT'], '0') + self.assertFalse({k for k in kw['env'] if 'TOKEN' in k or k == 'HOME'}) + self.assertTrue(0 < kw['timeout'] <= 60) + + def test_a_tagged_commit_and_any_doubt_are_refused(self): + cases = {'tagged': (SHA, 0, None, 'pushed by the upstream sync'), + 'git fails': (SHA, 128, None, 'cannot be told (git ls-remote exit 128)'), + 'git hangs': (SHA, 0, subprocess.TimeoutExpired('git', 30), 'cannot be told (TimeoutExpired)'), + 'no git': (SHA, 0, FileNotFoundError('git'), 'cannot be told (FileNotFoundError)')} + for name, (sha, code, error, words) in cases.items(): + with self.subTest(name): + self.assertIn(words, self.call(sha, code, error)[0]) + for sha in (None, '', SHA[:12], SHA.upper(), SHA + '0', '-' + SHA[1:], f'{SHA[:39]}\n'): + with self.subTest(sha=sha): + why, calls = self.call(sha) + self.assertIn('not a full sha', why) + self.assertEqual(calls, []) # nothing a run controls reaches git unchecked + + @unittest.skipUnless(shutil.which('git', path='/usr/bin:/bin'), 'git in /usr/bin or /bin') + def test_with_the_real_git(self): + with tempfile.TemporaryDirectory() as top: + remote, synced = sync_remote(top) + saved = ff.SYNC_REMOTE + try: + ff.SYNC_REMOTE = remote + self.assertIn('pushed by the upstream sync', REAL_MADE_BY_THE_SYNC(CFG, synced)) + self.assertIsNone(REAL_MADE_BY_THE_SYNC(CFG, SHA)) + ff.SYNC_REMOTE = remote.replace(top, os.path.join(top, 'gone')) + self.assertIn('cannot be told', REAL_MADE_BY_THE_SYNC(CFG, SHA)) + finally: + ff.SYNC_REMOTE = saved + + def test_the_sync_pushes_the_tag_this_rule_reads(self): + with open(os.path.join(HERE, 'fork-follow-push.sh')) as f: + text = f.read() + self.assertIn(f'\nSYNCED_TAG_PREFIX={SYNC_TAG}\n', text) + + +def beacon(free, at=T0): + return json.dumps({'at': at, 'free': free}) + + +class Route(unittest.TestCase): + def route(self, mode='auto', beacons=None, gates=None, e=None, event=PUSH, now=T0, nonce=NONCE): + e = dict(e or env()) + for name, value in (beacons or {}).items(): + e[POOLS[name]['beacon']] = value + (got_mode, out), log = quiet(ff.route, CFG, e, event, mode, gates, now=lambda: now, nonce=nonce) + return got_mode, out, log + + def fleet(self, out): + return {k: v for k, v in out.items() if v != HOSTED} + + def test_github_mode_is_hosted(self): + _, out, _ = self.route('github', beacons={FIRST: beacon(5), SECOND: beacon(5)}) + self.assertEqual(set(out.values()), {HOSTED}) + + def test_auto_without_a_heartbeat_is_hosted(self): + _, out, log = self.route() + self.assertEqual(self.fleet(out), {}) + self.assertIn('no heartbeat', log) + + def test_auto_fills_the_free_slots_pools_in_order(self): + _, out, _ = self.route(beacons={FIRST: beacon(1), SECOND: beacon(2)}) + self.assertEqual(self.fleet(out), {KEYS[0]: labels(FIRST, KEYS[0]), KEYS[1]: labels(SECOND, KEYS[1]), + KEYS[2]: labels(SECOND, KEYS[2])}) + + def test_stale_and_garbled_heartbeats_count_no_slot(self): + cases = {'stale': beacon(2, at=T0 - CFG['beacon_fresh_seconds'] - 1), 'from the future': beacon(2, at=T0 + 61), + 'not JSON': '{"at": 1', 'a list': '[]', 'no free': json.dumps({'at': T0}), + 'free is not a number': json.dumps({'at': T0, 'free': '2'}), 'at is a bool': '{"at": true, "free": 2}', + 'free is a fraction': json.dumps({'at': T0, 'free': 1.5}), 'negative': beacon(-3)} + for name, value in cases.items(): + with self.subTest(name): + _, out, log = self.route(beacons={FIRST: value}) + self.assertEqual(self.fleet(out), {}) + if name != 'negative': + self.assertRegex(log, 'cannot be read|s old') + + def test_a_closed_pool_is_a_notice_on_the_run_page(self): + """A new actions/runner release closes the pool (fail closed): auto goes hosted, and the run page says why.""" + closed = json.dumps({'at': T0, 'free': 0, 'why': 'the runner template is actions/runner 2.329.9, older than ' + 'the newest release 2.330.0: no runner starts until the owner refreshes the template ' + '(.github/fork-fleet.md, host step 2)'}) + _, out, log = self.route(beacons={FIRST: closed, SECOND: beacon(2, at=T0 - CFG['beacon_fresh_seconds'] - 1)}) + self.assertEqual(self.fleet(out), {}) + self.assertIn(f'::notice::fork-fleet: pool {FIRST}: no free slot counted: its dispatcher says: the runner ' + 'template is actions/runner 2.329.9, older than the newest release 2.330.0', log) + self.assertRegex(log, rf'::notice::fork-fleet: pool {SECOND}: no free slot counted: .* s old') + _, _, log = self.route() # never set up: a log line, no notice + self.assertIn(f'pool {FIRST}: no free slot counted: no heartbeat', log) + self.assertNotIn('::notice::', log) + + def test_fleet_mode_queues_the_rest_on_the_waiting_pool(self): + mode, out, _ = self.route('fleet', beacons={FIRST: beacon(1)}) + self.assertEqual(mode, 'fleet') + self.assertEqual(out[KEYS[0]], labels(FIRST, KEYS[0])) + for key in KEYS[1:]: + self.assertEqual(out[key], labels(WAITING, key)) + + def test_jobs_that_do_not_run_stay_hosted(self): + gates = {g: 'false' for j in CFG['jobs'] for g in j['gates']} + gates['rust'] = 'true' + running = [k for k, j in ff.job_keys(CFG) if ff.runs(j, gates)] + self.assertEqual(running, ['lint', 'rust']) # lint has no gates: it always runs + _, out, _ = self.route('fleet', gates=gates) + self.assertEqual(self.fleet(out), {'lint': labels(WAITING, 'lint'), 'rust': labels(WAITING, 'rust')}) + _, out, _ = self.route(beacons={SECOND: beacon(2)}, gates=gates) + self.assertEqual(self.fleet(out), {'lint': labels(SECOND, 'lint'), 'rust': labels(SECOND, 'rust')}) + + def test_untrusted_runs_are_hosted_in_every_mode(self): + for mode in ('auto', 'fleet'): + with self.subTest(mode): + _, out, log = self.route(mode, beacons={FIRST: beacon(5)}, e=env(**PR_ENV), + event=pr(head='someone/t3code')) + self.assertEqual(set(out.values()), {HOSTED}) + self.assertIn('only trusted runs', log) + + def test_the_upstream_syncs_push_run_is_hosted_in_every_mode(self): + for mode in ('auto', 'fleet'): + with self.subTest(mode): + _, out, log = self.route(mode, beacons={FIRST: beacon(5), SECOND: beacon(5)}, e=env(GITHUB_SHA=SYNCED)) + self.assertEqual(set(out.values()), {HOSTED}) + self.assertIn(f'::notice::fork-fleet: GitHub-hosted for every job: commit {SYNCED[:12]} was pushed ' + 'by the upstream sync', log) + + def test_each_run_gets_labels_of_its_own_that_the_dispatcher_reads_back(self): + _, a, _ = self.route('fleet', nonce=None) + _, b, _ = self.route('fleet', nonce=None) + for key in KEYS: + self.assertNotEqual(a[key], b[key]) + self.assertEqual(ff.fleet_job(CFG, a[key]), None) # runs-on adds the attempt; without it, no runner + self.assertEqual(ff.fleet_job(CFG, [a[key][0], a[key][1] + '-a3']), (POOLS[WAITING], key, 3)) + + def test_a_broken_setting_fails(self): + with self.assertRaises(ValueError): + self.route('flet') + + def outputs(self, **over): + with tempfile.TemporaryDirectory() as d: + out, summ, event = (os.path.join(d, n) for n in ('out', 'summary', 'event')) + with open(event, 'w') as f: + json.dump(PUSH, f) + e = env(GITHUB_OUTPUT=out, GITHUB_STEP_SUMMARY=summ, GITHUB_EVENT_PATH=event, FLEET_MODE='', + GATES='{"test": "false"}') + e.update(over) + got, log = quiet(ff.cmd_route, CFG, e) + self.assertEqual(got, 0) + with open(out) as f: + lines = dict(line.split('=', 1) for line in f.read().splitlines()) + with open(summ) as f: + self.assertIn('Runners', f.read()) + return lines, log + + def test_cmd_route_outputs(self): + lines, _ = self.outputs() + self.assertEqual((lines['mode'], lines['attempt'], lines['watch']), ('auto', '1', 'false')) + self.assertEqual(json.loads(lines['runners']), {k: HOSTED for k in KEYS}) + lines, _ = self.outputs(FLEET_MODE='fleet', GITHUB_RUN_ATTEMPT='3') + self.assertEqual((lines['mode'], lines['attempt'], lines['watch']), ('fleet', '3', 'true')) + runners = json.loads(lines['runners']) + self.assertEqual(runners['test-1'], HOSTED) # gate test is false: it only prints a skip + self.assertEqual(runners['lint'][0], POOLS[WAITING]['label']) + lines, _ = self.outputs(**{POOLS[FIRST]['beacon']: beacon(1, at=int(ff.time.time()))}) + self.assertEqual(lines['watch'], 'true') + + def test_cmd_route_with_unreadable_outputs_counts_every_job(self): + lines, log = self.outputs(FLEET_MODE='fleet', GATES='{') + self.assertIn('::warning::', log) + self.assertNotIn(HOSTED, json.loads(lines['runners']).values()) + + +class Watch(unittest.TestCase): + def watch(self, jobs, mode='', want=None, attempt='1', trouble=None): + gh = FakeGitHub() + gh.add(a_run(status='in_progress', attempt=int(attempt)), *jobs) + if trouble: + gh.trouble.append((r'GET actions/runs/5/attempts/\d+/jobs', trouble)) + clock = [T0] + + def sleep(s): + clock[0] += s + want = sum(1 for j in jobs if ff.is_fleet(CFG, j)) if want is None else want + e = {'GITHUB_RUN_ID': '5', 'GITHUB_RUN_ATTEMPT': attempt, 'FLEET_MODE': mode, + 'FLEET_RUNNERS': json.dumps({f'k{i}': labels(WAITING, 'lint') for i in range(want)})} + got, log = quiet(ff.watch, CFG, e, gh, now=lambda: clock[0], sleep=sleep) + return got, gh.writes(), log, clock[0] - T0, gh + + def test_a_job_waiting_past_the_bound_cancels_the_run(self): + for mode, bound in (('', CFG['stuck_seconds']), ('fleet', CFG['fleet_stuck_seconds'])): + with self.subTest(mode or 'auto'): + got, writes, log, waited, _ = self.watch([a_job(11, labels(WAITING, 'lint'))], mode=mode) + self.assertEqual((got, writes, waited), (1, [('POST', 'actions/runs/5/cancel')], bound)) + self.assertIn('::error::', log) + + def test_the_wait_counts_from_the_jobs_creation(self): + got, writes, _, waited, _ = self.watch([a_job(11, labels(WAITING, 'lint'), created=T0 - 590)]) + self.assertEqual((got, waited), (1, 30)) + + def test_every_job_started(self): + got, writes, _, waited, _ = self.watch([a_job(11, labels(WAITING, 'lint'), status='in_progress', runner='r')]) + self.assertEqual((got, writes, waited), (0, [], 0)) + + def test_a_routed_job_that_never_shows_up_ends_the_watch(self): + got, writes, _, waited, _ = self.watch([a_job(11, labels(WAITING, 'lint'), status='in_progress', runner='r')], + want=2) + self.assertEqual((got, writes, waited), (0, [], ff.SHOW_UP)) + + def test_hosted_jobs_are_not_watched(self): + got, writes, _, waited, _ = self.watch([a_job(11, [HOSTED])], want=0) + self.assertEqual((got, writes, waited), (0, [], 0)) + + def test_a_truncated_or_garbled_reply_is_never_red(self): + for name, error in {'truncated': http.client.IncompleteRead(b'{"jobs": [', 400), + 'not JSON': json.JSONDecodeError('Expecting value', '{"jo', 4), + 'no answer': urllib.error.URLError('timed out')}.items(): + with self.subTest(name): + got, writes, log, waited, _ = self.watch([a_job(11, labels(WAITING, 'lint'))], trouble=error) + self.assertEqual((got, writes), (0, [])) + self.assertEqual(waited, CFG['stuck_seconds'] + ff.SHOW_UP) + self.assertIn('::warning::', log) + + def test_a_re_run_watches_its_own_attempt(self): + _, _, _, _, gh = self.watch([a_job(11, labels(WAITING, 'lint'), status='in_progress', runner='r')], + attempt='2') + self.assertIn('actions/runs/5/attempts/2/jobs', [p for _, p, _ in gh.calls]) + + +class Sweep(unittest.TestCase): + def sweep(self, run, *jobs, mode='', head=SHA, at=T0 + 800, setup=None): + gh = FakeGitHub() + gh.add(run, *jobs) + gh.branches[run['head_branch']] = head + if setup: + setup(gh) + clock = [at] + + def sleep(s): + clock[0] += s + got, log = quiet(ff.sweep, CFG, {'FLEET_MODE': mode}, gh, now=lambda: clock[0], sleep=sleep) + return got, [p for m, p in gh.writes()], log + + def never_started(self): + return a_job(11, labels(WAITING, 'lint'), status='completed', conclusion='cancelled', completed=T0 + 700) + + def started(self, conclusion='failure', step='cancelled'): + return a_job(11, labels(WAITING, 'lint'), status='completed', conclusion=conclusion, runner='fleet-vm-11', + completed=T0 + 300, steps=[{'name': 'Test', 'conclusion': step}]) + + def test_a_job_that_waited_too_long_is_re_run(self): + got, posts, _ = self.sweep(a_run(status='completed', conclusion='cancelled'), self.never_started()) + self.assertEqual((got, posts), (0, ['actions/runs/5/rerun'])) + + def test_a_stuck_queued_job_cancels_then_re_runs(self): + def finish(gh): + def on_cancel(rid): + gh.runs[rid].update(status='completed', conclusion='cancelled') + gh.jobs[rid] = [self.never_started()] + gh.on_cancel = on_cancel + _, posts, _ = self.sweep(a_run(status='in_progress'), a_job(11, labels(WAITING, 'lint')), setup=finish) + self.assertEqual(posts, ['actions/runs/5/cancel', 'actions/runs/5/rerun']) + + def test_a_queued_job_not_yet_stuck_is_left(self): + _, posts, _ = self.sweep(a_run(status='in_progress'), a_job(11, labels(WAITING, 'lint'), created=T0 + 500)) + self.assertEqual(posts, []) + + def test_a_cancel_that_does_not_finish_is_left_for_the_next_sweep(self): + _, posts, log = self.sweep(a_run(status='in_progress'), a_job(11, labels(WAITING, 'lint'))) + self.assertEqual(posts, ['actions/runs/5/cancel']) + self.assertIn('next sweep', log) + + def test_a_moved_or_deleted_branch_is_not_re_run(self): + _, posts, _ = self.sweep(a_run(status='completed', conclusion='cancelled'), self.never_started(), head='def') + self.assertEqual(posts, []) + _, posts, _ = self.sweep(a_run(status='completed', conclusion='cancelled'), self.never_started(), + setup=lambda gh: gh.branches.clear()) + self.assertEqual(posts, []) + + def test_every_attempt_of_a_trusted_run(self): + _, posts, _ = self.sweep(a_run(status='completed', conclusion='failure', attempt=3), self.started()) + self.assertEqual(posts, ['actions/runs/5/rerun']) + + def test_a_lost_runner_is_re_run(self): + _, posts, _ = self.sweep(a_run(status='completed', conclusion='failure'), self.started()) + self.assertEqual(posts, ['actions/runs/5/rerun']) + + def test_a_persons_cancel_stands(self): + """A run a person cancelled while a fleet job ran: the job may end failed with no failed step of its own, + like a lost runner. Removing the cancelled check in rescue_reason turns this red.""" + _, posts, _ = self.sweep(a_run(status='completed', conclusion='cancelled'), self.started()) + self.assertEqual(posts, []) + + def test_a_failed_test_stays_red(self): + _, posts, _ = self.sweep(a_run(status='completed', conclusion='failure'), self.started(step='failure')) + self.assertEqual(posts, []) + + def test_untrusted_runs_are_never_touched(self): + stuck = a_job(11, labels(WAITING, 'lint')) + cases = { + 'a stranger\'s run': a_run(actor='someone', status='in_progress'), + 'a fork\'s pull request': a_run(event='pull_request', branch='x', head_repo='someone/t3code', + status='in_progress'), + 'the sweep\'s own re-run': a_run(attempt=2, triggering='github-actions[bot]', status='in_progress'), + 'the upstream sync\'s push run': a_run(sha=SYNCED, status='in_progress'), + } + for name, run in cases.items(): + with self.subTest(name): + _, posts, log = self.sweep(run, stuck) + self.assertEqual(posts, []) + self.assertIn('not a trusted run', log) + with self.subTest(name + ', finished'): + done = dict(run, status='completed', conclusion='failure') + _, posts, _ = self.sweep(done, self.started()) + self.assertEqual(posts, []) + + def test_fleet_mode_re_runs_nothing(self): + _, posts, _ = self.sweep(a_run(status='completed', conclusion='cancelled'), self.never_started(), mode='fleet') + self.assertEqual(posts, []) + _, posts, _ = self.sweep(a_run(status='completed', conclusion='failure'), self.started(), mode='fleet') + self.assertEqual(posts, []) + + def test_fleet_mode_cancels_a_run_that_waited_past_its_bound(self): + """A re-run of failed jobs in fleet mode has no watch of its own: the sweep reports the wait instead.""" + bound = CFG['fleet_stuck_seconds'] + _, posts, _ = self.sweep(a_run(status='in_progress'), a_job(11, labels(WAITING, 'lint')), mode='fleet', + at=T0 + bound - 1) + self.assertEqual(posts, []) + _, posts, log = self.sweep(a_run(status='in_progress'), a_job(11, labels(WAITING, 'lint')), mode='fleet', + at=T0 + bound) + self.assertEqual(posts, ['actions/runs/5/cancel']) + self.assertIn('::error::', log) + _, posts, _ = self.sweep(a_run(actor='someone', status='in_progress'), a_job(11, labels(WAITING, 'lint')), + mode='fleet', at=T0 + bound) + self.assertEqual(posts, []) + + def test_hosted_jobs_are_not_the_sweeps_business(self): + _, posts, _ = self.sweep(a_run(status='completed', conclusion='failure'), + a_job(11, [HOSTED], status='completed', conclusion='failure', runner='GitHub Actions 1')) + self.assertEqual(posts, []) + + def test_github_trouble_is_a_warning_and_the_next_sweep_tries_again(self): + got, posts, log = self.sweep(a_run(status='completed', conclusion='cancelled'), self.never_started(), + setup=lambda gh: gh.trouble.append((r'GET actions/runs/5/jobs.*', + http.client.IncompleteRead(b'{', 9)))) + self.assertEqual((got, posts), (1, [])) + self.assertIn('next sweep', log) + + +class FakeLauncher: + """A pool's launch command as fork-fleet-launch.sh answers: the host's units and its memory.""" + + def __init__(self, pool): + self.pool, self.units, self.memory_ok, self.template = pool, set(), True, NEWEST_RUNNER + self.started, self.stopped, self.fits_asked, self.pruned, self.fail = [], [], [], 0, None + + def run(self, cmd, input=None, capture_output=True, text=True, timeout=None): + launch = POOLS[self.pool]['launch'] + assert cmd[:len(launch)] == launch and capture_output and text and timeout, cmd + verb, args = cmd[len(launch)], cmd[len(launch) + 1:] + if self.fail == verb: + return subprocess.CompletedProcess(cmd, 1, '', 'fork-fleet-launch: systemctl failed\n') + out, code = '', 0 + if verb == 'active': + out = ''.join(f'{n}\n' for n in sorted(self.units)) + 'someone-else-1\n' + elif verb == 'fits': + self.fits_asked.append(int(args[0])) + ok = self.memory_ok(int(args[0])) if callable(self.memory_ok) else self.memory_ok + code = 0 if ok else 1 + elif verb == 'start': + self.started.append((args[0], input)) + self.units.add(args[0]) + elif verb == 'stop': + self.stopped.append(args[0]) + self.units.discard(args[0]) + elif verb == 'prune': + self.pruned += 1 + elif verb == 'ready': + if tuple(map(int, self.template.split('.'))) < tuple(map(int, args[0].split('.'))): + return subprocess.CompletedProcess(cmd, 1, '', f'fork-fleet-launch: the runner template is actions/runner ' + f'{self.template}, older than the newest release {args[0]}: no ' + 'runner starts until the owner refreshes the template\n') + else: + raise AssertionError(cmd) + return subprocess.CompletedProcess(cmd, code, out, '') + + +class Dispatch(unittest.TestCase): + def setUp(self): + self.gh = FakeGitHub() + self.fake = {name: FakeLauncher(name) for name in POOLS} + self.hosts = {name: ff.Host(POOLS[name], run=f.run) for name, f in self.fake.items()} + self.state = ff.new_state() + self.clock = [T0] + self.log = '' + + def dispatch(self, state=None): + _, log = quiet(ff.dispatch_pass, CFG, self.gh, self.hosts, self.state if state is None else state, + now=lambda: self.clock[0]) + self.log += log + return log + + def jits(self): + return [d for m, p, d in self.gh.calls if p == 'actions/runners/generate-jitconfig'] + + def started(self, pool=WAITING): + return [n for n, _ in self.fake[pool].started] + + def name(self, jid, pool=WAITING): + return f'{POOLS[pool]["label"]}-{jid}' + + def test_a_trusted_job_gets_one_runner_made_for_it(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.dispatch() + self.assertEqual(self.jits(), [{'name': self.name(11), 'runner_group_id': 1, 'labels': job_labels(WAITING, 'lint'), + 'work_folder': '_work'}]) + jit = f'SklUc2VjcmV0{self.gh.runners[0]["id"]}==' + self.assertEqual(self.fake[WAITING].started, + [(self.name(11), f'ACTIONS_RUNNER_INPUT_JITCONFIG={jit}\nFORK_FLEET_RUN=5\nFORK_FLEET_ATTEMPT=1\n')]) + self.assertNotIn(jit, self.log) # the JIT config is a credential: never printed + + def test_the_owners_pull_request_and_a_re_run(self): + self.gh.own_pr() + self.gh.add(a_run(event='pull_request', branch='fork/x', prs=[7]), a_job(11, job_labels(FIRST, 'lint'))) + self.gh.add(a_run(6, attempt=2), a_job(12, job_labels(WAITING, 'rust', attempt=2))) + self.dispatch() + self.assertEqual(self.started(FIRST), [self.name(11, FIRST)]) + self.assertEqual(self.fake[WAITING].started[0][1].splitlines()[1:], ['FORK_FLEET_RUN=6', 'FORK_FLEET_ATTEMPT=2']) + + def test_a_job_of_a_newer_attempt_than_the_run_read_waits_a_pass(self): + """A re-run between the run list and the job list: a runner made for the old attempt would have its job + refused by the gate. The next pass reads the new attempt.""" + self.gh.add(a_run(attempt=1), dict(a_job(11, job_labels(WAITING, 'lint', attempt=2)), run_attempt=2)) + self.dispatch() + self.assertEqual((self.jits(), self.started()), ([], [])) + self.gh.runs[5]['run_attempt'] = 2 + self.dispatch() + self.assertEqual(self.fake[WAITING].started[0][1].splitlines()[1:], ['FORK_FLEET_RUN=5', 'FORK_FLEET_ATTEMPT=2']) + + def test_untrusted_runs_get_no_runner_and_say_so_once(self): + def fork_pr(gh): + gh.add(a_run(event='pull_request', branch='x', head_repo='someone/t3code'), a_job(11, job_labels(WAITING, 'lint'))) + + def stranger(gh): + gh.add(a_run(actor='someone'), a_job(11, job_labels(WAITING, 'lint'))) + + def sweep_rerun(gh): + gh.add(a_run(attempt=2, triggering='github-actions[bot]'), a_job(11, job_labels(WAITING, 'lint', attempt=2))) + + def someone_elses_pr(gh): + gh.own_pr(user='someone') + gh.add(a_run(event='pull_request', branch='x', prs=[7]), a_job(11, job_labels(WAITING, 'lint'))) + + def upstream_sync(gh): + gh.add(a_run(sha=SYNCED), a_job(11, job_labels(WAITING, 'lint'))) + for setup in (fork_pr, stranger, sweep_rerun, someone_elses_pr, upstream_sync): + with self.subTest(setup.__name__): + self.setUp() + setup(self.gh) + self.dispatch() + self.dispatch() + self.assertEqual((self.jits(), self.started()), ([], [])) + self.assertEqual(self.log.count('not a trusted run'), 1) + + def test_only_the_exact_label_pair_gets_a_runner(self): + label = POOLS[WAITING]['label'] + other = POOLS[FIRST]['label'] + for name, asked in { + 'the pool label alone': [label], + 'the pool label and self-hosted': [label, 'self-hosted'], + 'three labels': job_labels(WAITING, 'lint') + ['linux'], + 'another pool\'s run label': [label, f'{other}-lint-{NONCE}-a1'], + 'an unknown job key': [label, f'{label}-nope-{NONCE}-a1'], + 'a short nonce': [label, f'{label}-lint-0123-a1'], + 'no attempt (route\'s labels as they are)': labels(WAITING, 'lint'), + 'another attempt of the run': job_labels(WAITING, 'lint', attempt=2), + 'attempt 0': [label, f'{label}-lint-{NONCE}-a0'], + 'one label twice': [label, label], + 'self-hosted alone': ['self-hosted'], + }.items(): + with self.subTest(name): + self.setUp() + self.gh.add(a_run(), a_job(11, asked)) + self.dispatch() + self.assertEqual((self.jits(), self.started()), ([], [])) + + def test_no_runner_while_another_runs_job_could_take_it(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.gh.add(a_run(6, actor='someone'), a_job(21, ['Self-Hosted', 'LINUX'])) + self.dispatch() + self.dispatch() + self.assertEqual(self.started(), []) + self.assertEqual(self.log.count('could take its runner'), 1) + self.gh.jobs[6] = [a_job(21, ['Self-Hosted', 'LINUX'], status='completed', conclusion='cancelled')] + self.dispatch() + self.assertEqual(self.started(), [self.name(11)]) + + def test_jobs_of_one_run_and_hosted_jobs_are_no_rivals(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint')), a_job(12, job_labels(WAITING, 'rust')), a_job(13, [HOSTED])) + self.dispatch() + self.assertEqual(self.started(), [self.name(11), self.name(12)]) + + def test_slots(self): + self.gh.add(a_run(), *[a_job(10 + i, job_labels(WAITING, k)) for i, k in enumerate(('lint', 'rust', 'build'))]) + self.dispatch() + self.dispatch() + self.assertEqual(self.started(), [self.name(10), self.name(11)]) + self.assertEqual(POOLS[WAITING]['slots'], 2) + + def test_memory(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint')), a_job(12, job_labels(WAITING, 'rust'))) + self.fake[WAITING].memory_ok = False + self.dispatch() + self.dispatch() + self.assertEqual(self.started(), []) + self.assertEqual(self.log.count(f'waits for {MEMORY["lint"]} MB'), 1) + self.setUp() + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint')), a_job(12, job_labels(WAITING, 'rust'))) + self.fake[WAITING].memory_ok = lambda mb: mb <= MEMORY['lint'] + MEMORY['rust'] - 1 + self.dispatch() + self.assertEqual(self.started(), [self.name(11)]) # the second asks with the first one's memory added + self.assertEqual(self.fake[WAITING].fits_asked[:2], [MEMORY['lint'], MEMORY['lint'] + MEMORY['rust']]) + + def test_a_name_taken_meanwhile_is_left_to_the_next_pass(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.gh.trouble.append(('POST actions/runners/generate-jitconfig', http_error(409))) + self.dispatch() + self.assertEqual(self.started(), []) + self.gh.trouble.clear() + self.dispatch() + self.assertEqual(self.started(), [self.name(11)]) + self.gh.trouble.append(('POST actions/runners/generate-jitconfig', http_error(500))) + self.gh.add(a_run(6), a_job(12, job_labels(WAITING, 'rust'))) + with self.assertRaises(urllib.error.HTTPError): + self.dispatch() + + def registration(self, jid, status='online', busy=False, pool=WAITING, key='lint', rid=None): + self.gh.runners.append({'id': rid or 500 + jid, 'name': self.name(jid, pool), 'status': status, 'busy': busy, + 'labels': [{'name': x} for x in ['self-hosted', 'Linux'] + job_labels(pool, key)]}) + + def test_leftovers(self): + self.registration(40, status='offline') # no runner behind it: deleted + self.registration(41, status='offline') # its unit is starting: kept + self.fake[WAITING].units.add(self.name(41)) + self.registration(42, busy=True) # running a job: kept + self.gh.runners.append({'id': 77, 'name': 'someone-else-3', 'status': 'offline', 'busy': False, 'labels': []}) + self.dispatch() + self.assertEqual(sorted(r['name'] for r in self.gh.runners), + sorted([self.name(41), self.name(42), 'someone-else-3'])) + self.assertEqual(self.fake[WAITING].stopped, []) + self.assertEqual(self.fake[WAITING].pruned, 1) + + def test_a_registration_gone_already_is_fine(self): + self.registration(40, status='offline') + self.gh.trouble.append((r'DELETE actions/runners/\d+', http_error(404))) + self.dispatch() + self.assertIn('deleted', self.log) + + def test_an_idle_runner_whose_job_went_away_is_stopped(self): + self.registration(40) + self.fake[WAITING].units.add(self.name(40)) + for _ in range(CFG['dispatch']['idle_passes'] - 1): + self.dispatch() + self.assertEqual(self.fake[WAITING].stopped, []) + self.dispatch() + self.assertEqual(self.fake[WAITING].stopped, [self.name(40)]) + + def test_an_idle_runner_another_job_could_take_is_stopped_at_once(self): + self.registration(40) + self.fake[WAITING].units.add(self.name(40)) + self.gh.add(a_run(6, actor='someone'), a_job(41, [POOLS[WAITING]['label']])) + self.dispatch() + self.assertEqual(self.fake[WAITING].stopped, [self.name(40)]) + + def test_a_runner_still_coming_up_for_its_queued_job_is_not_idle(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.dispatch() + self.gh.runners[0]['status'] = 'online' + for _ in range(CFG['dispatch']['idle_passes'] + 1): + self.dispatch() + self.assertEqual((self.fake[WAITING].stopped, self.started()), ([], [self.name(11)])) + + def test_a_restarted_dispatcher_starts_nothing_twice(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.dispatch() + self.dispatch(ff.new_state()) + self.assertEqual((len(self.jits()), self.started()), (1, [self.name(11)])) + self.assertEqual(len(self.gh.runners), 1) # the starting runner's registration is kept + + def test_at_most_max_tries_runners_for_one_job(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + for _ in range(ff.MAX_TRIES + 2): + self.dispatch() + self.fake[WAITING].units.clear() # its runner died before it took the job + self.gh.runners.clear() + self.assertEqual(len(self.started()), ff.MAX_TRIES) + self.assertEqual(self.log.count('none took it'), 1) + + def test_a_host_that_cannot_answer_skips_only_its_pool(self): + """s1/s2 item 3: one dispatcher for both pools; one host down leaves the other pool working.""" + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.gh.add(a_run(6), a_job(12, job_labels(FIRST, 'rust'))) + self.registration(40, status='offline', pool=FIRST) # FIRST's: not touched while its host cannot answer + self.fake[FIRST].fail = 'active' + self.assertEqual(self.dispatch().count(f'pool {FIRST} is left alone'), 1) + self.assertEqual((self.started(WAITING), self.started(FIRST)), ([self.name(11)], [])) + self.assertIn(self.name(40, FIRST), [r['name'] for r in self.gh.runners]) + self.assertEqual((self.fake[FIRST].pruned, self.fake[WAITING].pruned), (0, 1)) + beat = json.loads(self.gh.variables[POOLS[FIRST]['beacon']]) + self.assertEqual(beat['free'], 0) + self.assertIn('systemctl failed', beat['why']) + self.assertEqual(json.loads(self.gh.variables[POOLS[WAITING]['beacon']])['free'], POOLS[WAITING]['slots'] - 1) + self.fake[FIRST].fail = None + self.dispatch() + self.assertEqual(self.started(FIRST), [self.name(12, FIRST)]) + self.assertNotIn(self.name(40, FIRST), [r['name'] for r in self.gh.runners]) + self.assertNotIn('why', json.loads(self.gh.variables[POOLS[FIRST]['beacon']])) + + def test_a_failed_start_skips_only_its_pool_and_is_tried_again(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint')), a_job(12, job_labels(WAITING, 'rust'))) + self.gh.add(a_run(6), a_job(13, job_labels(FIRST, 'build'))) + self.fake[WAITING].fail = 'start' + log = self.dispatch() + self.assertEqual(log.count(f'pool {WAITING} is left alone'), 1) + self.assertEqual(len([j for j in self.jits() if j['name'].startswith(POOLS[WAITING]['label'])]), 1) + self.assertEqual(self.started(FIRST), [self.name(13, FIRST)]) + self.assertEqual(json.loads(self.gh.variables[POOLS[WAITING]['beacon']])['free'], 0) + self.fake[WAITING].fail = None + self.dispatch() # its registration (no runner behind it) is deleted, the next pass starts the job + self.dispatch() + self.assertEqual(sorted(self.started()), [self.name(11), self.name(12)]) + + def test_github_trouble_still_stops_the_pass(self): + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.gh.trouble.append(('GET actions/runners', http_error(502))) + with self.assertRaises(urllib.error.HTTPError): + self.dispatch() + self.assertEqual(self.started(), []) + + def test_an_outdated_template_starts_nothing_and_says_why(self): + """Item 6, fail closed: a template older than the newest actions/runner release starts no runner (it would + update itself in its copy); its leftovers are still cleaned up and the heartbeat says why.""" + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.registration(40, status='offline') + self.fake[WAITING].template = '2.329.9' + self.dispatch() + log = self.dispatch() + self.assertEqual((self.jits(), self.started()), ([], [])) + self.assertEqual(self.log.count('refreshes the template'), 1) + self.assertEqual(log, '') + self.assertNotIn(self.name(40), [r['name'] for r in self.gh.runners]) # cleanup goes on + self.assertEqual(self.fake[WAITING].pruned, 2) + beat = json.loads(self.gh.variables[POOLS[WAITING]['beacon']]) + self.assertEqual(beat['free'], 0) + self.assertIn('refreshes the template', beat['why']) + free, why = ff.beacon_free(CFG, POOLS[WAITING], {POOLS[WAITING]['beacon']: self.gh.variables[POOLS[WAITING]['beacon']]}, + T0) + self.assertEqual(free, 0) + self.assertIn('older than the newest release 2.330.0', why) + self.assertEqual(json.loads(self.gh.variables[POOLS[FIRST]['beacon']])['free'], POOLS[FIRST]['slots']) + self.fake[WAITING].template = '2.330.0' + self.dispatch() + self.assertEqual(self.started(), [self.name(11)]) + + def test_the_newest_runner_release_is_read_hourly(self): + reads = lambda: len([c for c in self.gh.calls if c[1] == '/repos/actions/runner/releases/latest']) + self.gh.trouble.append(('GET /repos/actions/runner/releases/latest', http_error(502))) + with self.assertRaises(urllib.error.HTTPError): + self.dispatch() # never read: no runner starts before the template is checked + self.gh.trouble.clear() + self.dispatch() + self.clock[0] += ff.RELEASE_EVERY - 1 + self.dispatch() + self.assertEqual(reads(), 2) + self.clock[0] += 1 + self.gh.newest = '2.331.0' + self.gh.trouble.append(('GET /repos/actions/runner/releases/latest', http_error(502))) + self.dispatch() # read failed: the last one is kept + self.assertEqual(self.state['newest'][0], NEWEST_RUNNER) + self.gh.trouble.clear() + self.clock[0] += ff.RELEASE_EVERY + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.dispatch() + self.assertEqual(self.state['newest'][0], '2.331.0') + self.assertEqual(self.started(), []) # the template (2.330.0) is now one release behind + self.gh.newest = 'garbage' + self.clock[0] += ff.RELEASE_EVERY + self.dispatch() + self.assertEqual(self.state['newest'][0], '2.331.0') + + def beacons(self): + return [(m, p) for m, p, _ in self.gh.calls if 'actions/variables' in p] + + def test_heartbeats(self): + vm, first = POOLS[WAITING]['beacon'], POOLS[FIRST]['beacon'] + self.dispatch() + self.assertEqual(self.gh.variables, {vm: f'{{"at":{T0},"free":{POOLS[WAITING]["slots"]}}}', + first: f'{{"at":{T0},"free":{POOLS[FIRST]["slots"]}}}'}) + self.assertEqual(self.beacons(), [('PATCH', f'actions/variables/{first}'), ('POST', 'actions/variables'), + ('PATCH', f'actions/variables/{vm}'), ('POST', 'actions/variables')]) + self.gh.calls.clear() + self.clock[0] += CFG['dispatch']['every_seconds'] + self.dispatch() + self.assertEqual(self.beacons(), []) # unchanged and recent: not written again + self.clock[0] = T0 + CFG['dispatch']['beacon_seconds'] + self.dispatch() + self.assertEqual(len(self.beacons()), 2) + self.gh.calls.clear() + self.clock[0] += 5 + self.gh.add(a_run(), *[a_job(10 + i, job_labels(WAITING, k)) for i, k in enumerate(('lint', 'rust', 'build'))]) + self.dispatch() + self.assertEqual(self.beacons(), [('PATCH', f'actions/variables/{vm}')]) # changed: written at once + self.assertEqual(json.loads(self.gh.variables[vm])['free'], 0) # 2 slots, 2 started, 1 more waiting + + def test_a_queued_job_not_started_yet_counts_against_the_free_slots(self): + """Item 4: a fleet job the dispatcher holds back (here: a job of another run could take its runner) still + needs a slot. Left out of the count, route would send one job more to this pool than it can run.""" + self.gh.add(a_run(), a_job(11, job_labels(WAITING, 'lint'))) + self.gh.add(a_run(6, actor='someone'), a_job(21, ['self-hosted'])) + self.dispatch() + self.assertEqual(self.started(), []) + self.assertEqual(json.loads(self.gh.variables[POOLS[WAITING]['beacon']])['free'], POOLS[WAITING]['slots'] - 1) + + def test_no_free_slot_without_memory_for_the_biggest_job(self): + self.fake[WAITING].memory_ok = lambda mb: mb < max(MEMORY.values()) + self.dispatch() + self.assertEqual(json.loads(self.gh.variables[POOLS[WAITING]['beacon']])['free'], 0) + + def test_a_beacon_write_other_than_missing_fails(self): + self.gh.trouble.append((r'PATCH actions/variables/\w+', http_error(403))) + with self.assertRaises(urllib.error.HTTPError): + self.dispatch() + + def test_runner_env_refuses_a_bad_config(self): + for jit in (None, '', 'a b', 'a\nFORK_FLEET_RUN=1', 'x' * 65537): + with self.subTest(repr(jit)[:20]), self.assertRaises(ValueError): + ff.runner_env(jit, a_run()) + + def test_the_token_file(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, 'token') + with open(path, 'w') as f: + f.write('tok\n') + os.chmod(path, 0o600) + self.assertEqual(ff.read_token(path), 'tok') + os.chmod(path, 0o640) + with self.assertRaises(ValueError): + ff.read_token(path) + with open(path, 'w') as f: + f.write('\n') + os.chmod(path, 0o600) + with self.assertRaises(ValueError): + ff.read_token(path) + + def test_one_dispatcher_at_a_time(self): + with tempfile.TemporaryDirectory() as d: + fd = ff.take_lock(os.path.join(d, 'lock')) + try: + with self.assertRaises(ValueError): + ff.take_lock(os.path.join(d, 'lock')) + finally: + os.close(fd) + os.close(ff.take_lock(os.path.join(d, 'lock'))) + os.close(ff.take_lock(os.path.join(d, 'run', 'fork-fleet', 'lock'))) # after a boot: no directory yet + self.assertEqual(stat.S_IMODE(os.stat(os.path.join(d, 'run', 'fork-fleet')).st_mode), 0o700) + + def test_one_lock_per_pool(self): + """Item 3: a dispatcher per pool may run beside another; two for one pool may not.""" + with tempfile.TemporaryDirectory() as d: + first = ff.take_locks(d, [FIRST]) + second = ff.take_locks(d, [SECOND]) + self.assertEqual(sorted(os.listdir(d)), sorted([f'dispatch-{FIRST}.lock', f'dispatch-{SECOND}.lock'])) + with self.assertRaises(ValueError): + ff.take_locks(d, [FIRST, SECOND]) + for fd in second: + os.close(fd) + both = None + with self.assertRaises(ValueError): + both = ff.take_locks(d, [SECOND, FIRST]) # SECOND's lock is let go again: FIRST is held + self.assertIsNone(both) + os.close(ff.take_locks(d, [SECOND])[0]) + for fd in first: + os.close(fd) + for fd in ff.take_locks(d, [FIRST, SECOND, FIRST]): + os.close(fd) + + def test_the_dispatcher_takes_its_pools_locks(self): + with tempfile.TemporaryDirectory() as d: + token = os.path.join(d, 'token') + with open(token, 'w') as f: + f.write('tok\n') + os.chmod(token, 0o600) + held = ff.take_locks(os.path.join(d, 'locks'), [FIRST]) + argv = ['dispatch', '--pool', FIRST, '--token-file', token, '--lock-dir', os.path.join(d, 'locks'), '--once'] + with self.assertRaisesRegex(ValueError, f'dispatch-{FIRST}.lock'): + ff.main(argv, env={}) + for fd in held: + os.close(fd) + with self.assertRaises(SystemExit), redirect_stdout(io.StringIO()), \ + __import__('contextlib').redirect_stderr(io.StringIO()): + ff.main(['dispatch', '--lock', os.path.join(d, 'x'), '--once'], env={}) # one lock for all: gone + + def test_an_unknown_pool_is_refused_before_anything_else(self): + with self.assertRaises(ValueError): + ff.main(['dispatch', '--pool', 'nope', '--token-file', '/nonexistent', '--once'], env={}) + + +@unittest.skipUnless(sys.platform.startswith('linux') and os.geteuid() != 0, 'Linux, not as root') +class Launcher(unittest.TestCase): + """fork-fleet-launch.sh with a fake systemd-run, systemctl, chown, pgrep, setpriv, ip, nft, iptables and id (it + runs here as this user, not root; LauncherAsRoot runs the real ones). The fake systemd-run records each unit it + starts, with its user, for `systemctl list-units` and `systemctl show` (with --wait, check-net's probe, it runs the + command itself, or exits $REC/probe-exit when that file exists). The fake ip keeps the namespaces and links + it made in $REC/netns and $REC/links; the fake iptables has a DOCKER-USER chain only when $REC/docker-user exists; + the fake id answers from $REC/id-- when that file exists.""" + + FAKES = { + 'systemd-run': '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$REC/systemd-run"\n' + 'printf "%s\\0" "$@" > "$REC/systemd-run.0"\n' + 'for a in "$@"; do case $a in EnvironmentFile=*) f=${a#EnvironmentFile=}; ' + 'cp -- "$f" "$REC/env"; stat -c %a -- "$f" > "$REC/env-mode" ;; esac; done\n' + # check-net's probe (--wait): exits $REC/probe-exit when that file exists, else runs here. + 'if [[ " $* " == *" --wait "* ]]; then ' + '[[ ! -e $REC/probe-exit ]] || exit "$(cat "$REC/probe-exit")"; ' + 'while [[ $1 != -- ]]; do shift; done; shift; exec "$@"; fi\n' + 'code=$(cat "$REC/systemd-run-exit" 2>/dev/null || echo 0)\n' + 'if [[ $code == 0 ]]; then while (($#)); do case $1 in --unit) unit=$2 ;; --uid) user=$2 ;; ' + 'esac; shift; done; printf "%s.service loaded active running x\\n" "$unit" >> "$REC/units"; ' + 'printf "%s\\n" "$user" > "$REC/user-$unit.service"; fi\nexit "$code"\n', + 'systemctl': '#!/usr/bin/env bash\n[[ ! -e $REC/systemctl-fails ]] || exit 1\n' + 'case $1 in list-units) cat -- "$REC/units" 2>/dev/null; exit 0 ;; ' + 'show) cat -- "$REC/user-${!#}" 2>/dev/null; exit 0 ;; ' + 'stop) printf "%s\\n" "$*" >> "$REC/stopped" ;; *) exit 2 ;; esac\n', + 'chown': '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$REC/chown"\n', + # Processes of a runner user: a file $REC/procs-UID; setpriv's kill(-1) ends them unless procs-stay exists. + 'pgrep': '#!/usr/bin/env bash\n[[ $1 == -u ]] || exit 3\nprintf "%s\\n" "$2" >> "$REC/pgrep"\n' + '[[ -e $REC/procs-$2 ]]\n', + 'setpriv': '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$REC/setpriv"\n' + '[[ -e $REC/procs-stay ]] || rm -f -- "$REC"/procs-*\n', + 'nft': '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$REC/nft-args"\ncat > "$REC/nft"\n[[ ! -e $REC/nft-fails ]]\n', + 'ip': '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$REC/ip"\n[[ ! -e $REC/ip-fails ]] || exit 1\n' + 'case "$*" in "-n "*" link show lo") grep -qxF -- "$2" "$REC/netns" 2>/dev/null ;; ' + '"netns add "*) printf "%s\\n" "$3" >> "$REC/netns" ;; ' + '"link show "*) grep -qxF -- "$3" "$REC/links" 2>/dev/null ;; ' + '"link add "*) printf "%s\\n" "$3" >> "$REC/links" ;; esac\n', + 'iptables': '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$REC/iptables"\n[[ -e $REC/docker-user ]] || exit 1\n' + 'case $3 in -S) exit 0 ;; ' + '-C) [[ ! -e $REC/iptables-C-exit ]] || exit "$(cat "$REC/iptables-C-exit")"; ' + 'grep -qxF -- "${*:5}" "$REC/docker-user" ;; ' + '-I) printf "%s\\n" "${*:6}" >> "$REC/docker-user" ;; *) exit 2 ;; esac\n', + 'id': '#!/usr/bin/env bash\nf=$REC/id${1}-${!#}\nif [[ -e $f ]]; then cat -- "$f"; exit 0; fi\n' + 'for p in /usr/bin/id /bin/id; do [[ -x $p ]] && exec "$p" "$@"; done\nexit 127\n', + } + PRIVATE4 = ('0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 ' + '192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4') + USERS = ('nobody', 'daemon') # two slots: two users of their own (in CI and here, each in its own group only) + NAME = 'fleet-vm-t3code-11' + JIT = 'SklUc2VjcmV0' + INPUT = f'ACTIONS_RUNNER_INPUT_JITCONFIG={JIT}\nFORK_FLEET_RUN=5\nFORK_FLEET_ATTEMPT=1\n' + + def setUp(self): + for user in self.USERS: + try: + pwd.getpwnam(user) + except KeyError: + self.skipTest(f'no user {user}') + if subprocess.run(['id', '-G', user], capture_output=True, text=True).stdout.split() != \ + [str(pwd.getpwnam(user).pw_gid)]: + self.skipTest(f'{user} is in other groups here') + self.d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.d, True) + self.rec = self.path('rec') + os.mkdir(self.rec) + os.mkdir(self.path('bin')) + for name, text in self.FAKES.items(): + self.write(f'bin/{name}', text.replace('$REC', self.rec), 0o755) + self.template = self.path('template') + os.makedirs(self.path('template/bin')) + self.write('template/run.sh', '#!/bin/sh\n', 0o755) + self.write('template/bin/Runner.Listener', 'x', 0o755) + self.write('template/.fork-fleet-version', f'{NEWEST_RUNNER}\n', 0o644) + os.mkdir(self.path('linger')) + self.write('resolv.conf', 'nameserver 1.1.1.1\n', 0o644) + self.write('ip_forward', '1\n', 0o644) + for d in ('template', 'template/bin'): + os.chmod(self.path(d), 0o755) # whatever this machine's umask is + self.hook = self.write('hook.sh', '#!/bin/sh\n', 0o755) + self.write('meminfo', 'MemTotal: 16384000 kB\nMemAvailable: 8388608 kB\n', 0o644) + self.conf() + + def path(self, rel): + return os.path.join(self.d, rel) + + def write(self, rel, text, mode): + p = self.path(rel) + with open(p, 'w') as f: + f.write(text) + os.chmod(p, mode) + return p + + def conf(self, extra='', **over): + settings = dict(RUNNER_USERS=','.join(self.USERS), TEMPLATE=self.template, WORK_ROOT=self.path('var/runners'), + RUN_DIR=self.path('run'), HOOK=self.hook, LINGER_DIR=self.path('linger'), + RESERVE_MB='2048', MAX_SECONDS='3600', NET_BASE='10.204', RESOLV_CONF=self.path('resolv.conf'), + IP_FORWARD=self.path('ip_forward'), + MEMINFO=self.path('meminfo'), SYSTEMD_RUN=self.path('bin/systemd-run'), + SYSTEMCTL=self.path('bin/systemctl')) + settings.update(over) + self.write('launch.conf', '# fleet\n\n' + ''.join(f'{k}={v}\n' for k, v in settings.items()) + extra, 0o600) + + def env(self): + return {'PATH': f'{self.path("bin")}:/usr/bin:/bin', 'FORK_FLEET_LAUNCH_CONF': self.path('launch.conf')} + + def launch(self, *args, stdin=''): + return subprocess.run(['bash', LAUNCH, *args], input=stdin, env=self.env(), capture_output=True, text=True, + timeout=60) + + def rec_text(self, name): + try: + with open(os.path.join(self.rec, name)) as f: + return f.read() + except FileNotFoundError: + return None + + def sandbox(self, user): + """The unit's sandbox, start's and check-net's alike: its own network namespace, the private ranges refused in + the kernel too, its own /run, /dev/shm, /tmp and /var/tmp (so root never cleans a shared one), no /home, no + other user's processes, its own IPC.""" + props = (f'NetworkNamespacePath=/run/netns/fork-fleet-{user}', f'IPAddressDeny={self.PRIVATE4} ::/0', + 'IPAddressAllow=localhost', + 'TemporaryFileSystem=/run /dev/shm:size=1G,mode=1777 /tmp:size=1G,mode=1777 /var/tmp:size=1G,mode=1777', + f'BindReadOnlyPaths={self.path("resolv.conf")}:{os.path.realpath("/etc/resolv.conf")}', + 'NoNewPrivileges=yes', 'ProtectSystem=strict', 'ProtectHome=yes', 'ProtectProc=invisible', + 'PrivateIPC=yes', 'RemoveIPC=yes') + return [a for prop in props for a in ('-p', prop)] + + def test_start(self): + got = self.launch('start', self.NAME, stdin=self.INPUT) + self.assertEqual(got.returncode, 0, got.stderr) + run_dir, dest = self.path('run'), self.path(f'var/runners/{self.NAME}') + args = self.rec_text('systemd-run').splitlines() + # Exactly these: no PrivateTmp (it leaves /dev/shm shared), nothing dropped. + self.assertEqual(args, ['--quiet', '--unit', f'fork-fleet-{self.NAME}', '--collect', '--uid', 'nobody', + '--working-directory', dest, '-p', 'Type=exec', '-p', 'KillMode=control-group', + '-p', f'EnvironmentFile={run_dir}/{self.NAME}.env', *self.sandbox('nobody'), + '-p', f'ReadWritePaths={dest}', '-p', 'RuntimeMaxSec=3600', '--', f'{dest}/run.sh']) + # HOME and TMPDIR inside the copy, which goes once its unit is gone: nothing of a job reaches the next one. + self.assertEqual(self.rec_text('env'), self.INPUT + f'HOME={dest}/home\nTMPDIR={dest}/tmp\n' + f'ACTIONS_RUNNER_HOOK_JOB_STARTED={self.hook}\n') + for d in ('home', 'tmp'): + self.assertEqual(stat.S_IMODE(os.stat(f'{dest}/{d}').st_mode), 0o700) + self.assertEqual(self.rec_text('env-mode').strip(), '600') + self.assertFalse(os.path.exists(f'{run_dir}/{self.NAME}.env')) # read by systemd, then removed + self.assertEqual(os.stat(f'{dest}/run.sh').st_ino, os.stat(f'{self.template}/run.sh').st_ino) + self.assertEqual(self.rec_text('chown'), f'-- nobody: {dest} {dest}/home {dest}/tmp\n') # nothing else of it + self.assertEqual(self.rec_text('pgrep'), f'{pwd.getpwnam("nobody").pw_uid}\n') # its processes were checked + self.assertEqual(stat.S_IMODE(os.stat(dest).st_mode), 0o700) + self.assertEqual(stat.S_IMODE(os.stat(self.path('var/runners')).st_mode), 0o711) + self.assertEqual(stat.S_IMODE(os.stat(run_dir).st_mode), 0o700) + self.assertNotIn(self.JIT, got.stdout + got.stderr) + + def test_the_dispatchers_host_drives_it(self): + pool = dict(POOLS[WAITING], launch=['bash', LAUNCH]) + host = ff.Host(pool, run=partial(subprocess.run, env=self.env())) + host.start(f'{pool["label"]}-11', ff.runner_env('A' * 4000, a_run())) + self.assertIn('FORK_FLEET_RUN=5', self.rec_text('env')) + self.write('rec/units', f'fork-fleet-{pool["label"]}-11.service loaded active running x\n', 0o644) + self.assertEqual(host.units(), {f'{pool["label"]}-11'}) + self.assertTrue(host.fits(1024)) + self.assertFalse(host.fits(8192)) + host.stop(f'{pool["label"]}-11') + host.prune() + with self.assertRaises(ff.HostError): + host.start(f'{pool["label"]}-11', ff.runner_env('A', a_run())) # that copy is still there + + def test_start_refuses_bad_input(self): + good = dict(line.split('=', 1) for line in self.INPUT.splitlines()) + lines = lambda **over: ''.join(f'{k}={v}\n' for k, v in {**good, **over}.items() if v is not None) + cases = { + 'the hook set from the input': (self.INPUT + 'ACTIONS_RUNNER_HOOK_JOB_STARTED=/tmp/x\n', 'unexpected'), + 'another variable': (self.INPUT + 'PATH=/tmp\n', 'unexpected'), + 'a line twice': (self.INPUT + 'FORK_FLEET_RUN=6\n', 'twice'), + 'no attempt': (lines(FORK_FLEET_ATTEMPT=None), 'needs'), + 'nothing': ('', 'needs'), + 'a config with a space': (lines(ACTIONS_RUNNER_INPUT_JITCONFIG='a b'), 'not well formed'), + 'a config with a substitution': (lines(ACTIONS_RUNNER_INPUT_JITCONFIG='$(id)'), 'not well formed'), + 'an empty config': (lines(ACTIONS_RUNNER_INPUT_JITCONFIG=''), 'not well formed'), + 'a config too long': (lines(ACTIONS_RUNNER_INPUT_JITCONFIG='A' * 65537), 'not well formed'), + 'run 0': (lines(FORK_FLEET_RUN='0'), 'not well formed'), + 'a run that is no number': (lines(FORK_FLEET_RUN='5x'), 'not well formed'), + } + for name, (stdin, word) in cases.items(): + with self.subTest(name): + got = self.launch('start', self.NAME, stdin=stdin) + self.assertNotEqual(got.returncode, 0) + self.assertIn(word, got.stderr) + self.assertNotIn(self.JIT, got.stderr) + for bad in ('../x-1', 'Fleet-1', 'fleet-vm', 'a' * 70 + '-1', '-1', 'x-1/y'): + with self.subTest(bad): + got = self.launch('start', bad, stdin=self.INPUT) + self.assertIn('not a runner name', got.stderr) + self.assertIsNone(self.rec_text('systemd-run')) + + def test_the_longest_config_is_taken(self): + got = self.launch('start', self.NAME, stdin=self.INPUT.replace(self.JIT, 'A' * 65536)) + self.assertEqual(got.returncode, 0, got.stderr) + + def refused(self, word, *args, stdin=None): + got = self.launch(*(args or ('start', self.NAME)), stdin=self.INPUT if stdin is None else stdin) + self.assertNotEqual(got.returncode, 0) + self.assertIn(word, got.stderr) + self.assertIsNone(self.rec_text('systemd-run')) + + def test_a_configured_template_is_refused(self): + for f in ('.env', '.path', '.runner', '.credentials', '.credentials_rsaparams'): + with self.subTest(f): + self.write(f'template/{f}', 'ACTIONS_RUNNER_HOOK_JOB_STARTED=\n', 0o644) + self.refused(f'{f} exists') + os.remove(self.path(f'template/{f}')) + + def test_a_template_others_may_change_is_refused(self): + os.chmod(self.path('template/bin/Runner.Listener'), 0o775) + self.refused('others may write') + os.chmod(self.path('template/bin/Runner.Listener'), 0o755) + os.chmod(self.path('template/bin'), 0o757) + self.refused('others may write') + + def test_a_template_that_is_a_symlink_is_refused(self): + os.rename(self.template, self.path('real')) + os.symlink(self.path('real'), self.template) + self.refused('symlink') + + def test_a_hook_others_may_change_is_refused(self): + os.chmod(self.hook, 0o775) + self.refused('writable by its owner only') + + def test_a_conf_others_may_change_is_refused(self): + os.chmod(self.path('launch.conf'), 0o620) + self.refused('writable by its owner only', 'active') + + def test_the_conf_is_read_never_run(self): + mark = self.path('mark') + self.conf(RUNNER_USERS=f'$(touch${{IFS}}{mark})') + self.refused('not a list of user names') + self.assertFalse(os.path.exists(mark)) + self.conf(extra='PATH=/tmp\n') + self.refused('not a setting', 'active') + self.conf(extra=f'HOOK=$(touch {mark})\n') + self.refused('not a setting', 'active') + self.assertFalse(os.path.exists(mark)) + + def test_the_runner_user_must_be_a_user_of_its_own(self): + for user in ('root', pwd.getpwuid(os.geteuid()).pw_name): + with self.subTest(user): + self.conf(RUNNER_USERS=f'nobody,{user}') + self.refused('a user of its own') + self.conf(RUNNER_USERS='nobody,nobody') + self.refused('are one user') + self.conf(RUNNER_USERS='nobody,fork-fleet-no-such-user') + self.refused('no user fork-fleet-no-such-user') + self.conf() + self.write('linger/daemon', '', 0o644) + self.refused('daemon lingers') + + def start(self, name): + got = self.launch('start', name, stdin=self.INPUT) + self.assertEqual(got.returncode, 0, got.stderr) + args = self.rec_text('systemd-run').splitlines() + return args[args.index('--uid') + 1] + + def test_each_slot_runs_as_a_user_no_other_unit_runs_as(self): + """Item 1: two runners at once never share a user (so never a HOME, TMPDIR or config).""" + self.assertEqual([self.start('fleet-vm-t3code-11'), self.start('fleet-vm-t3code-12')], list(self.USERS)) + os.remove(os.path.join(self.rec, 'systemd-run')) + self.refused('no slot is free', 'start', 'fleet-vm-t3code-13') + self.write('rec/units', 'fork-fleet-fleet-vm-t3code-12.service loaded active running x\n', 0o644) # 11 ended + self.assertEqual(self.start('fleet-vm-t3code-14'), 'nobody') + self.assertFalse(os.path.exists(self.path('var/runners/fleet-vm-t3code-11'))) # its copy went first + os.remove(os.path.join(self.rec, 'user-fork-fleet-fleet-vm-t3code-12.service')) + os.remove(os.path.join(self.rec, 'systemd-run')) + self.refused('runs as no runner user', 'start', 'fleet-vm-t3code-15') + + def test_a_runner_users_leftover_processes_are_killed_first(self): + uid = pwd.getpwnam('nobody').pw_uid + self.write(f'rec/procs-{uid}', '', 0o644) + self.assertEqual(self.start(self.NAME), 'nobody') + self.assertEqual(self.rec_text('setpriv'), f'--reuid={uid} --regid={pwd.getpwnam("nobody").pw_gid} ' + '--clear-groups -- /bin/bash --norc --noprofile -c kill -s KILL -- -1\n') + self.write('rec/units', '', 0o644) + os.remove(os.path.join(self.rec, 'systemd-run')) + self.write(f'rec/procs-{uid}', '', 0o644) + self.write('rec/procs-stay', '', 0o644) + self.refused('processes of nobody are still running', 'start', 'fleet-vm-t3code-12') + + def test_the_copy_must_be_on_the_templates_filesystem(self): + """cp -al hard-links: across filesystems it would fail half-way (or copy, writable by the job).""" + shm = '/dev/shm' + if not os.path.isdir(shm) or os.stat(shm).st_dev == os.stat(self.d).st_dev: + self.skipTest('no second filesystem to test with') + other = tempfile.mkdtemp(dir=shm) + self.addCleanup(shutil.rmtree, other, True) + self.conf(WORK_ROOT=os.path.join(other, 'runners')) + self.refused('must be on one filesystem') + + def test_ready(self): + """Item 6: the template's runner against the newest release; fail closed.""" + major, minor, patch = map(int, NEWEST_RUNNER.split('.')) + for newest, ok in ((NEWEST_RUNNER, True), (f'{major}.{minor - 1}.9', True), (f'{major}.{minor}.{patch + 1}', False), + (f'{major}.{minor + 1}.0', False), (f'{major + 1}.0.0', False)): + with self.subTest(newest): + got = self.launch('ready', newest) + self.assertEqual(got.returncode == 0, ok, got.stderr) + if not ok: + self.assertIn(f'actions/runner {NEWEST_RUNNER}, older than the newest release {newest}', got.stderr) + self.assertIn('refreshes the template (.github/fork-fleet.md, host step 2)', got.stderr) + self.assertIn('not a version', self.launch('ready', 'v2.330.0;id').stderr) + self.write('template/.fork-fleet-version', 'latest\n', 0o644) + self.assertIn('not a version', self.launch('ready', NEWEST_RUNNER).stderr) + self.refused('not a version') + os.remove(self.path('template/.fork-fleet-version')) + self.assertIn('has no .fork-fleet-version', self.launch('ready', NEWEST_RUNNER).stderr) + self.refused('has no .fork-fleet-version') + + def test_a_name_that_ran_already_is_refused(self): + self.assertEqual(self.launch('start', self.NAME, stdin=self.INPUT).returncode, 0) + os.remove(os.path.join(self.rec, 'systemd-run')) + self.refused('ran already') + + def test_a_failed_start_leaves_no_environment_file(self): + self.write('rec/systemd-run-exit', '3', 0o644) + got = self.launch('start', self.NAME, stdin=self.INPUT) + self.assertNotEqual(got.returncode, 0) + self.assertEqual([n for n in os.listdir(self.path('run')) if n.endswith('.env')], []) + + def test_active(self): + self.write('rec/units', 'fork-fleet-fleet-vm-t3code-11.service loaded active running x\n' + 'fork-fleet-bogus.service loaded active running x\n' + 'fork-fleet-fleet-pc-t3code-12.service loaded deactivating stop-sigterm y\n', 0o644) + got = self.launch('active') + self.assertEqual((got.returncode, got.stdout), (0, 'fleet-vm-t3code-11\nfleet-pc-t3code-12\n')) + self.write('rec/systemctl-fails', '', 0o644) + self.assertNotEqual(self.launch('active').returncode, 0) + + def test_fits(self): + free = 8388608 // 1024 - 2048 + self.assertEqual(self.launch('fits', str(free)).returncode, 0) + self.assertEqual(self.launch('fits', str(free + 1)).returncode, 1) + self.assertIn('not a number', self.launch('fits', '1e3').stderr) + self.write('meminfo', 'MemTotal: 1 kB\n', 0o644) + self.assertIn('no MemAvailable', self.launch('fits', '1').stderr) + + def test_stop(self): + self.assertEqual(self.launch('stop', self.NAME).returncode, 0) + self.assertEqual(self.rec_text('stopped'), f'stop -- fork-fleet-{self.NAME}.service\n') + self.assertIn('not a runner name', self.launch('stop', '*').stderr) + + def test_prune(self): + root, run = self.path('var/runners'), self.path('run') + for d in (root, run, self.path('elsewhere')): + os.makedirs(d) + os.chmod(d, 0o755) # whatever this machine's umask is + for name in ('fleet-vm-t3code-11', 'fleet-vm-t3code-12', 'notes'): + os.makedirs(f'{root}/{name}/_work') + self.write('run/fleet-vm-t3code-12.env', 'x', 0o600) + os.symlink(self.path('elsewhere'), f'{root}/fleet-vm-t3code-13') + self.write('rec/units', 'fork-fleet-fleet-vm-t3code-11.service loaded active running x\n', 0o644) + self.write('rec/systemctl-fails', '', 0o644) + self.assertNotEqual(self.launch('prune').returncode, 0) # no unit list: nothing is removed + self.assertEqual(len(os.listdir(root)), 4) + os.remove(os.path.join(self.rec, 'systemctl-fails')) + self.assertEqual(self.launch('prune').returncode, 0) + self.assertEqual(sorted(os.listdir(root)), ['fleet-vm-t3code-11', 'fleet-vm-t3code-13', 'notes']) + self.assertEqual(os.listdir(run), ['launch.lock']) + self.assertTrue(os.path.isdir(self.path('elsewhere'))) + + # Item 1 of round 3: each runner user's own network namespace, internet only, made (or found) before every start. + NOBODY_NET, DAEMON_NET = '10.204.255.248', '10.204.0.4' # uid % 16384 * 4 from NET_BASE: 65534 and 1 + + def ip_calls(self, user, net, made=True): + uid = pwd.getpwnam(user).pw_uid + ns, host, peer, base = f'fork-fleet-{user}', f'ffh-{uid}', f'ffp-{uid}', net.rsplit('.', 1)[0] + last = int(net.rsplit('.', 1)[1]) + return [f'-n {ns} link show lo', *([f'netns add {ns}'] if made else []), f'link show {host}', + *([f'link add {host} type veth peer name {peer} netns {ns}'] if made else []), + f'addr replace {base}.{last + 1}/30 dev {host}', f'link set {host} up', f'-n {ns} link set lo up', + f'-n {ns} addr replace {base}.{last + 2}/30 dev {peer}', f'-n {ns} link set {peer} up', + f'-n {ns} route replace default via {base}.{last + 1}'] + + def test_each_runner_gets_a_network_of_its_own_with_internet_only(self): + self.assertEqual(self.start('fleet-vm-t3code-11'), 'nobody') + self.assertEqual(self.rec_text('ip').splitlines(), self.ip_calls('nobody', self.NOBODY_NET)) + self.assertEqual(self.rec_text('nft-args'), '-f -\n') + table = self.rec_text('nft') + # One transaction: the old table (if any) goes and the new one comes, or neither (nft -f is atomic). + self.assertTrue(table.startswith('table inet fork_fleet\ndelete table inet fork_fleet\ntable inet fork_fleet {')) + self.assertIn('elements = { ' + ', '.join(self.PRIVATE4.split()) + ' }', table) + for rule in ('type filter hook input priority -10; policy accept;', + 'iifname "ffh-*" counter reject with icmpx admin-prohibited', # the host itself: nothing of it + 'iifname "ffh-*" ip daddr @private4 counter reject with icmpx admin-prohibited', + 'iifname "ffh-*" meta nfproto ipv6 counter drop', + 'oifname "ffh-*" ct state != { established, related } counter drop', # nothing opens to it + 'ip saddr 10.204.0.0/16 oifname != "ffh-*" masquerade'): + with self.subTest(rule): + self.assertIn(f' {rule}\n', table) + self.assertEqual(len(re.findall(r'iifname "ffh-\*"', table)), 3) + self.assertEqual(self.start('fleet-vm-t3code-12'), 'daemon') + self.assertEqual(self.rec_text('ip').splitlines(), + self.ip_calls('nobody', self.NOBODY_NET) + self.ip_calls('daemon', self.DAEMON_NET)) + # The unit is put in it, and refused the same ranges again in the kernel (IPAddressDeny), should the table go. + args = self.rec_text('systemd-run').splitlines() + self.assertEqual(args[args.index('NetworkNamespacePath=/run/netns/fork-fleet-daemon') - 1], '-p') + self.assertIn(f'IPAddressDeny={self.PRIVATE4} ::/0', args) + # The next job of that user: its namespace and link are there, nothing is made twice, the table is made again. + os.remove(os.path.join(self.rec, 'ip')) + self.write('rec/units', 'fork-fleet-fleet-vm-t3code-12.service loaded active running x\n', 0o644) + self.assertEqual(self.start('fleet-vm-t3code-13'), 'nobody') + self.assertEqual(self.rec_text('ip').splitlines(), self.ip_calls('nobody', self.NOBODY_NET, made=False)) + self.assertEqual(self.rec_text('nft-args'), '-f -\n' * 3) + self.assertEqual(self.rec_text('netns'), 'fork-fleet-nobody\nfork-fleet-daemon\n') + self.assertNotIn(' -I ', self.rec_text('iptables')) # no DOCKER-USER chain here: nothing added + + def test_no_runner_starts_without_its_network(self): + """Fail closed: when any part of the network cannot be made, or the host does not forward, no unit starts.""" + cases = {'nft fails': ('rec/nft-fails', 'network: nft -f - failed'), + 'ip fails': ('rec/ip-fails', 'network: ip netns add fork-fleet-nobody failed')} + for name, (mark, word) in cases.items(): + with self.subTest(name): + self.write(mark, '', 0o644) + self.refused(word) + os.remove(self.path(mark)) + self.write('ip_forward', '0\n', 0o644) + self.refused('ip_forward is not 1') + self.write('ip_forward', '1\n', 0o644) + self.write('resolv.conf', '# none\nsearch lan\n', 0o644) + self.refused('names no nameserver') + os.remove(self.path('resolv.conf')) + self.refused('resolv.conf is missing') + self.write('resolv.conf', 'nameserver 1.1.1.1\n', 0o666) + self.refused('writable by its owner only') + for bad in ('10.300', '192.168', '10.204.0', '10.04', '10.x'): + with self.subTest(bad): + self.conf(NET_BASE=bad) + self.refused('NET_BASE is 10.N', 'active') + + def test_dockers_forward_policy_gets_its_two_rules_once(self): + """On a host with Docker (FORWARD policy DROP) the table's traffic needs two accepts in DOCKER-USER: added + once, never twice; an iptables that gives no clear answer stops the start.""" + self.write('rec/docker-user', '', 0o644) + self.start('fleet-vm-t3code-11') + self.start('fleet-vm-t3code-12') + # (the fake appends; iptables -I 1 puts each at the top) + self.assertEqual(self.rec_text('docker-user'), '-o ffh-+ -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n' + '-i ffh-+ -j ACCEPT\n') + self.assertEqual(sum(1 for line in self.rec_text('iptables').splitlines() if ' -I ' in f' {line} '), 2) + os.remove(os.path.join(self.rec, 'systemd-run')) + self.write('rec/units', '', 0o644) + self.write('rec/iptables-C-exit', '4', 0o644) + self.refused('iptables -C DOCKER-USER gave no answer (exit 4)', 'start', 'fleet-vm-t3code-13') + + def test_a_runner_user_in_another_group_is_refused(self): + """A runner user also in, say, docker (root on the host) or adm (the logs) is never handed a job.""" + gid = pwd.getpwnam('daemon').pw_gid + self.write('rec/id-G-daemon', f'{gid} 999\n', 0o644) + self.refused('daemon is in other groups') + self.write('rec/id-G-daemon', f'{gid}\n', 0o644) + self.assertEqual(self.start(self.NAME), 'nobody') + + def test_two_users_that_would_share_a_network_are_refused(self): + self.write('rec/id-u-daemon', '16382\n', 0o644) # 65534 % 16384 too: nobody's /30 + self.refused('daemon and nobody would share one network') + + def test_one_launcher_at_a_time(self): + """launch.lock: a second launcher (the other pool's dispatcher) waits until the first is done.""" + import fcntl, time + os.mkdir(self.path('run'), 0o700) + with open(self.path('run/launch.lock'), 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + stdin = self.write('input', self.INPUT, 0o600) + with open(stdin) as i, open(os.path.join(self.rec, 'err'), 'w') as e: + proc = subprocess.Popen(['bash', LAUNCH, 'start', self.NAME], stdin=i, stdout=subprocess.DEVNULL, + stderr=e, env=self.env()) + self.addCleanup(proc.kill) + time.sleep(3) + self.assertIsNone(proc.poll()) # waiting for the lock: nothing made, nothing started + self.assertIsNone(self.rec_text('systemd-run')) + self.assertIsNone(self.rec_text('ip')) + self.assertEqual(proc.wait(30), 0, self.rec_text('err')) + self.assertIsNotNone(self.rec_text('systemd-run')) + + # check-net, the proof of the network (.github/fork-fleet.md, host step 6). + def server(self): + import http.server, threading + srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) + srv.RequestHandlerClass.log_message = lambda *a: None + threading.Thread(target=srv.serve_forever, daemon=True).start() + self.addCleanup(srv.server_close) + self.addCleanup(srv.shutdown) + return srv.server_address[1] + + def closed_port(self): + import socket + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + def test_check_net_refuses_what_proves_nothing(self): + port = self.server() + cases = {('check-net',): 'usage: check-net', ('check-net', 'https://example.com'): 'usage: check-net', + ('check-net', 'ftp://example.com', f'127.0.0.1:{port}'): 'not a plain http(s) URL', + ('check-net', 'https://example.com/$(id)', f'127.0.0.1:{port}'): 'not a plain http(s) URL', + ('check-net', 'https://example.com', f'localhost:{port}'): 'not an IPv4 HOST:PORT', + ('check-net', 'https://example.com', f'127.0.0.1:{port};id'): 'not an IPv4 HOST:PORT', + # A service this host does not reach either: its refusal inside would say nothing. + ('check-net', 'https://example.com', f'127.0.0.1:{port}', f'127.0.0.1:{self.closed_port()}'): + 'would prove nothing'} + for args, word in cases.items(): + with self.subTest(args): + self.refused(word, *args) + + def check_net(self, *targets, url='https://example.com/'): + return self.launch('check-net', url, *targets) + + def test_check_net_runs_its_probe_in_a_runners_own_sandbox(self): + port = self.server() + self.write('rec/probe-exit', '0', 0o644) + got = self.check_net(f'127.0.0.1:{port}') + self.assertEqual(got.returncode, 0, got.stderr) + self.assertIn('the runners reach https://example.com/ and none of the 1 services named', got.stdout) + with open(os.path.join(self.rec, 'systemd-run.0')) as f: + args = f.read().split('\0')[:-1] + dash = args.index('--') + # The very sandbox a job gets (the same props as start), as a free runner user, in its namespace. + self.assertEqual(args[:dash], ['--quiet', '--wait', '--pipe', '--collect', '--unit', 'fork-fleet-check-net', + '--uid', 'nobody', *self.sandbox('nobody'), '-p', 'RuntimeMaxSec=120']) + self.assertEqual(args[dash + 1:dash + 4], ['/bin/bash', '--norc', '--noprofile']) + self.assertEqual(args[dash + 4], '-c') + self.assertEqual(args[dash + 6:], ['check-net', 'https://example.com/', '127.0.0.1', str(port)]) + self.assertEqual(self.rec_text('ip').splitlines(), self.ip_calls('nobody', self.NOBODY_NET)) + self.write('rec/probe-exit', '1', 0o644) + got = self.check_net(f'127.0.0.1:{port}') + self.assertNotEqual(got.returncode, 0) + self.assertIn('NOT as it must be', got.stderr) + + def test_check_nets_probe_fails_on_anything_reachable(self): + """The probe itself, run here outside any namespace: the service this host reaches is reachable, so it fails.""" + port = self.server() + got = self.check_net(f'127.0.0.1:{port}', url=f'http://127.0.0.1:{port}/') + self.assertNotEqual(got.returncode, 0) + self.assertIn(f'FAIL 127.0.0.1:{port} is reachable from inside', got.stdout) + self.assertIn(f'ok http://127.0.0.1:{port}/ answers (HTTP 200)', got.stdout) + self.assertIn('NOT as it must be', got.stderr) + + def test_usage(self): + for args in ((), ('start',), ('active', 'x'), ('nope',), ('ready',)): + with self.subTest(args): + self.assertIn('usage', self.launch(*args).stderr) + self.assertIn('ready NEWEST', self.launch().stderr) + self.assertIn('check-net URL HOST:PORT...', self.launch().stderr) + + +# LauncherAsRoot's systemd-run for check-net: the unit's namespace and user, the rest of its sandbox left out. +NETNS_SYSTEMD_RUN = """#!/usr/bin/env bash +ns='' user='' +while (($#)); do + case $1 in + --uid) user=$2 && shift ;; + -p) [[ $2 != NetworkNamespacePath=* ]] || ns=${2#NetworkNamespacePath=}; shift ;; + --) shift && break ;; + esac + shift +done +[[ -n $ns && -n $user ]] || { echo "fake systemd-run: no namespace or no user" >&2; exit 99; } +exec nsenter --net="$ns" -- setpriv --reuid="$(id -u -- "$user")" --regid="$(id -g -- "$user")" --clear-groups -- "$@" +""" + + +@unittest.skipUnless(sys.platform.startswith('linux') and os.geteuid() == 0 and + os.environ.get('FORK_FLEET_ROOT_TESTS') == '1', + 'as root with FORK_FLEET_ROOT_TESTS=1 only: it creates and deletes users (CI: the workflow lint ' + 'job; elsewhere: a throwaway container)') +class LauncherAsRoot(unittest.TestCase): + """fork-fleet-launch.sh as root with two real runner users and the real chown, pgrep, setpriv and find (systemd-run + and systemctl stay fakes: no unit really starts; ip, nft and iptables are the fakes too, but for the network test, + which makes real namespaces). Item 1 of round 2: a slot's user cannot read or change another slot's copy, HOME or + start config, and nothing a job leaves (files in HOME, a running process) reaches the next job of that user. + Round 3: root deletes nothing in /dev/shm (the swap race), and check-net proves a runner's network for real.""" + + USERS = ('fork-fleet-t1', 'fork-fleet-t2') + NAME = Launcher.NAME + INPUT = Launcher.INPUT + + @classmethod + def setUpClass(cls): + for user in cls.USERS: + try: + pwd.getpwnam(user) + raise AssertionError(f'user {user} exists already: this test only runs where it may make its own') + except KeyError: + pass + for user in cls.USERS: + subprocess.run(['useradd', '--no-create-home', '--user-group', '--shell', '/usr/sbin/nologin', user], + check=True) + cls.addClassCleanup(subprocess.run, ['userdel', user], check=False) + cls.addClassCleanup(subprocess.run, ['pkill', '-KILL', '-u', user], check=False) # runs before userdel + + def setUp(self): + self.d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.d, True) + os.chmod(self.d, 0o711) # the runner users pass through to their copies, as through /var/lib + self.rec = self.path('rec') + for d in ('rec', 'bin', 'template/bin', 'linger'): + os.makedirs(self.path(d)) + os.chmod(self.rec, 0o700) + for name in ('systemd-run', 'systemctl', 'ip', 'nft', 'iptables'): + Launcher.write(self, f'bin/{name}', Launcher.FAKES[name].replace('$REC', self.rec), 0o755) + self.template = self.path('template') + Launcher.write(self, 'template/run.sh', '#!/bin/sh\n', 0o755) + Launcher.write(self, 'template/.fork-fleet-version', f'{NEWEST_RUNNER}\n', 0o644) + for d in ('template', 'template/bin'): + os.chmod(self.path(d), 0o755) + self.hook = Launcher.write(self, 'hook.sh', '#!/bin/sh\n', 0o755) + Launcher.write(self, 'meminfo', 'MemAvailable: 8388608 kB\n', 0o644) + Launcher.write(self, 'resolv.conf', 'nameserver 1.1.1.1\n', 0o644) + Launcher.write(self, 'ip_forward', '1\n', 0o644) + self.conf() + + path = Launcher.path + + def conf(self, **over): + settings = dict(RUNNER_USERS=','.join(self.USERS), TEMPLATE=self.template, WORK_ROOT=self.path('var/runners'), + RUN_DIR=self.path('run'), HOOK=self.hook, LINGER_DIR=self.path('linger'), + MEMINFO=self.path('meminfo'), RESOLV_CONF=self.path('resolv.conf'), + IP_FORWARD=self.path('ip_forward'), SYSTEMD_RUN=self.path('bin/systemd-run'), + SYSTEMCTL=self.path('bin/systemctl')) + settings.update(over) + Launcher.write(self, 'launch.conf', ''.join(f'{k}={v}\n' for k, v in settings.items() if v), 0o600) + + def launch(self, *args, stdin='', path=None): + return subprocess.run(['bash', LAUNCH, *args], input=stdin, capture_output=True, text=True, timeout=300, + env={'PATH': f'{path or self.path("bin")}:/usr/sbin:/usr/bin:/sbin:/bin', + 'FORK_FLEET_LAUNCH_CONF': self.path('launch.conf')}) + + def start(self, name): + got = self.launch('start', name, stdin=self.INPUT) + self.assertEqual(got.returncode, 0, got.stderr) + with open(os.path.join(self.rec, 'systemd-run')) as f: + args = f.read().splitlines() + return args[args.index('--uid') + 1], self.path(f'var/runners/{name}') + + def as_user(self, user, *cmd, **kw): + pw = pwd.getpwnam(user) + return subprocess.run(['setpriv', f'--reuid={pw.pw_uid}', f'--regid={pw.pw_gid}', '--clear-groups', '--', + *cmd], capture_output=True, text=True, timeout=30, **kw) + + def test_one_slots_user_cannot_read_or_change_the_others(self): + (u1, a), (u2, b) = self.start('fleet-vm-t3code-11'), self.start('fleet-vm-t3code-12') + self.assertEqual((u1, u2), self.USERS) + for user, copy_ in ((u1, a), (u2, b)): # each writes its own copy, HOME and TMPDIR + for rel in ('.runner', 'home/.vite-plus-cache', 'tmp/x'): + self.assertEqual(self.as_user(user, 'sh', '-c', f'echo secret > {copy_}/{rel}').returncode, 0) + for rel in ('.runner', 'home/.vite-plus-cache', 'tmp/x'): + with self.subTest(rel): + self.assertNotEqual(self.as_user(u2, 'cat', f'{a}/{rel}').returncode, 0) + self.assertNotEqual(self.as_user(u2, 'sh', '-c', f'echo evil >> {a}/{rel}').returncode, 0) + for target in (a, f'{a}/home', self.path('run'), self.path('var/runners')): + with self.subTest(target): + self.assertNotEqual(self.as_user(u2, 'ls', target).returncode, 0) + for target in (f'{a}/run.sh', f'{b}/run.sh', f'{self.template}/run.sh', self.hook): + with self.subTest(target): # the runner itself and the hook: root's, read-only to both + self.assertNotEqual(self.as_user(u2, 'sh', '-c', f'echo evil >> {target}').returncode, 0) + self.assertNotEqual(self.as_user(u2, 'mkdir', f'{a}/../x').returncode, 0) + with open(f'{a}/home/.vite-plus-cache') as f: + self.assertEqual(f.read(), 'secret\n') + + def test_nothing_a_job_leaves_reaches_the_next_job_of_its_user(self): + user, a = self.start(self.NAME) + self.assertEqual(user, self.USERS[0]) + other_user, b = self.start('fleet-vm-t3code-12') # the other slot: running, must keep its files + plant = (f'mkdir -p {a}/home/.vite-plus && echo x > {a}/home/.vite-plus/planted && ' + f'ln -s /etc/passwd {a}/home/link && echo x > {a}/tmp/planted') + self.assertEqual(self.as_user(user, 'sh', '-c', plant).returncode, 0) + pw = pwd.getpwnam(user) + left = subprocess.Popen(['setpriv', f'--reuid={pw.pw_uid}', f'--regid={pw.pw_gid}', '--clear-groups', '--', + 'sh', '-c', 'sleep 300 & exec sleep 300'], start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.addCleanup(left.kill) + reaper = __import__('threading').Thread(target=left.wait, daemon=True) # as init would: no zombie left + reaper.start() + # The first job's unit ends (the other slot's goes on); the next job gets the free user. + with open(os.path.join(self.rec, 'units'), 'w') as f: + f.write('fork-fleet-fleet-vm-t3code-12.service loaded active running x\n') + again, c = self.start('fleet-vm-t3code-13') + self.assertEqual(again, user) + reaper.join(10) + self.assertEqual(left.returncode, -signal.SIGKILL) + self.assertEqual(subprocess.run(['pgrep', '-u', str(pw.pw_uid)]).returncode, 1) # the background one too + self.assertFalse(os.path.exists(a)) # with its HOME and TMPDIR + self.assertEqual(os.listdir(f'{c}/home') + os.listdir(f'{c}/tmp'), []) + self.assertTrue(os.path.isdir(b)) + self.assertEqual(os.stat('/etc/passwd').st_size > 0, True) # the link went, not what it pointed at + # /tmp, /var/tmp, /dev/shm and /run: the unit's own memory file systems (systemd's, not tested here), gone + # with it; the launcher never deletes in a shared one (test_root_deletes_nothing_in_dev_shm). + + def as_user_bg(self, user, *cmd): + pw = pwd.getpwnam(user) + proc = subprocess.Popen(['setpriv', f'--reuid={pw.pw_uid}', f'--regid={pw.pw_gid}', '--clear-groups', '--', + *cmd], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def stop(): + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + self.addCleanup(stop) + return stop + + def test_root_deletes_nothing_in_dev_shm(self): + """The swap race (round 3, S-2), in the real /dev/shm: runner user B owns a directory there and lets user A's + job leave a file in it; while root readies A for its next job, B swaps that directory for a symlink to one + under /etc, again and again. Root must never delete through it: the launcher deletes nothing in /dev/shm (each + unit has a /dev/shm of its own, gone with it), so the file under /etc stays, every round.""" + a, b = self.USERS + pb = pwd.getpwnam(b) + victim_dir = tempfile.mkdtemp(prefix='fork-fleet-victim-', dir='/etc') + self.addCleanup(shutil.rmtree, victim_dir, True) + os.chmod(victim_dir, 0o755) + victim = os.path.join(victim_dir, 'planted') + bdir = tempfile.mkdtemp(prefix='fork-fleet-race-', dir='/dev/shm') + self.addCleanup(shutil.rmtree, bdir, True) + os.chown(bdir, pb.pw_uid, pb.pw_gid) + os.chmod(bdir, 0o755) + sub = os.path.join(bdir, 'sub') + # B's swap: sub a directory (with A's file), then a symlink to the directory under /etc, and back, forever + # (one process per step: each state lasts about a millisecond, long enough for a find and an rm to see it). + racer = f'cd {bdir} && while :; do mv -T sub real; mv -T link sub; mv -T sub link; mv -T real sub; done' + failed = [] + for round_ in range(40): + with open(victim, 'w') as f: + f.write('root\'s\n') + self.assertEqual(self.as_user(b, 'sh', '-c', f'cd {bdir} && rm -rf sub real link && mkdir -m 777 sub && ' + f'ln -s {victim_dir} link').returncode, 0) + self.assertEqual(self.as_user(a, 'sh', '-c', f'echo x > {sub}/planted').returncode, 0) # A's job + stop = self.as_user_bg(b, 'sh', '-c', racer) + with open(os.path.join(self.rec, 'units'), 'w') as f: + f.write('') # A's last unit ended: its user is free + got = self.launch('start', f'fleet-vm-t3code-{100 + round_}', stdin=self.INPUT) + stop() + self.assertTrue(os.path.exists(victim), f'round {round_}: root deleted {victim} through /dev/shm') + if got.returncode: + failed.append(f'round {round_}: {got.stderr.strip()}') + self.assertEqual(failed, []) + + def test_check_net_proves_a_runners_network(self): + """check-net with real namespaces and the real nft table (CI installs nftables): from inside a runner's + namespace, as its user, the host's loopback service, the host's own LAN, Tailscale-like and public-looking + addresses, a LAN host and a Tailscale-like resolver are closed, and a public web server answers. systemd-run is + a fake that enters the namespace and becomes the runner user: the unit's IPAddressDeny (the same ranges, the + kernel's second refusal) is not part of this test.""" + for tool in ('nft', 'ip', 'nsenter', 'curl', 'timeout'): + self.assertIsNotNone(shutil.which(tool), f'no {tool} here') + forward = '/proc/sys/net/ipv4/ip_forward' + with open(forward) as f: + if f.read().strip() != '1': + with open(forward, 'w') as w: # a throwaway machine (CI) or container only + w.write('1\n') + + def ip(*args): + subprocess.run(['ip', *args], check=True, capture_output=True, text=True) + + def later(*cmd): + self.addCleanup(subprocess.run, list(cmd), capture_output=True, check=False) + later('nft', 'delete', 'table', 'inet', 'fork_fleet') + for user in self.USERS: + later('ip', 'netns', 'del', f'fork-fleet-{user}') + # A LAN (192.168.77.0/24) with a host .2 that also has a Tailscale-like resolver 100.100.100.100, a "public" + # network 203.0.113.0/24 with a web server .2, and this host's own public-looking and Tailscale-like addresses. + for ns, link, mine, theirs in (('fft-lan', 'fft-lan0', '192.168.77.1', '192.168.77.2'), + ('fft-wan', 'fft-wan0', '203.0.113.1', '203.0.113.2')): + later('ip', 'netns', 'del', ns) + ip('netns', 'add', ns) + ip('link', 'add', link, 'type', 'veth', 'peer', 'name', 'eth0', 'netns', ns) + ip('addr', 'add', f'{mine}/24', 'dev', link) + ip('link', 'set', link, 'up') + ip('-n', ns, 'addr', 'add', f'{theirs}/24', 'dev', 'eth0') + ip('-n', ns, 'link', 'set', 'eth0', 'up') + ip('-n', ns, 'link', 'set', 'lo', 'up') + ip('-n', 'fft-lan', 'addr', 'add', '100.100.100.100/32', 'dev', 'lo') + ip('route', 'add', '100.100.100.100/32', 'via', '192.168.77.2') + later('ip', 'link', 'del', 'fft-dummy') + ip('link', 'add', 'fft-dummy', 'type', 'dummy') + for addr in ('198.51.100.7/32', '100.64.7.1/32'): + ip('addr', 'add', addr, 'dev', 'fft-dummy') + ip('link', 'set', 'fft-dummy', 'up') + import socket + for addr in (('127.0.0.1', 8384), ('0.0.0.0', 3773)): # a GUI-like one on loopback, a T3-like one on every address + srv = socket.create_server(addr) + self.addCleanup(srv.close) + web = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, web, True) + for cmd in (['nsenter', '--net=/run/netns/fft-lan', 'python3', '-I', '-c', + 'import socket, time\nss = [socket.create_server(("0.0.0.0", p)) for p in (22, 53)]\n' + 'time.sleep(600)'], + ['nsenter', '--net=/run/netns/fft-wan', 'python3', '-I', '-m', 'http.server', '80', '--bind', + '203.0.113.2', '--directory', web]): + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.addCleanup(proc.wait) + self.addCleanup(proc.kill) + import time + for host, port in (('192.168.77.2', 22), ('100.100.100.100', 53), ('203.0.113.2', 80)): + for _ in range(100): + try: + socket.create_connection((host, port), 1).close() + break + except OSError: + time.sleep(0.1) + else: + self.fail(f'{host}:{port} never listened') + os.makedirs(self.path('netbin')) + Launcher.write(self, 'netbin/systemd-run', NETNS_SYSTEMD_RUN, 0o755) + self.conf(SYSTEMD_RUN=self.path('netbin/systemd-run'), IP_FORWARD=forward) + targets = ('127.0.0.1:8384', '192.168.77.1:3773', '198.51.100.7:3773', '100.64.7.1:3773', '192.168.77.2:22', + '100.100.100.100:53') + got = self.launch('check-net', 'http://203.0.113.2/', *targets, path=self.path('netbin')) + self.assertEqual(got.returncode, 0, got.stdout + got.stderr) + for target in targets: + self.assertIn(f'ok {target} is closed', got.stdout) + self.assertIn('ok http://203.0.113.2/ answers (HTTP 200)', got.stdout) + self.assertIn(f'check-net: as {self.USERS[0]}, in its namespace fork-fleet-{self.USERS[0]}', got.stdout) + +class Wiring(unittest.TestCase): + """fork-tests.yml, fork-fleet.yml, fork-release.yml and the hook against the config.""" + + @classmethod + def setUpClass(cls): + import yaml + + def read(rel): + with open(os.path.join(ROOT, rel)) as f: + return f.read() + cls.text = read(WORKFLOW) + cls.tests = yaml.safe_load(cls.text) + cls.sweep = yaml.safe_load(read('.github/workflows/fork-fleet.yml')) + cls.follow = yaml.safe_load(read('.github/workflows/fork-follow.yml')) + cls.checks = yaml.safe_load(read('.github/workflows/fork-checks.yml')) + cls.release = yaml.safe_load(read('.github/workflows/fork-release.yml')) + cls.hook = read('.github/scripts/fork-fleet-hook.sh') + + def expected_runs_on(self, j): + """route's labels for the job, its run label with this attempt added (-a): a runner made for one + attempt can never be given a job of another (a re-run after a cancel would be refused by the gate).""" + v, out = CFG['variable'], 'needs.changes.outputs' + key = f"format('{j['job']}-{{0}}', matrix.shard)" if j.get('shards') else f"'{j['job']}'" + r = f"fromJSON({out}.runners || '{{}}')[{key}]" + return (f"${{{{ vars.{v} != 'github' && ({out}.fleet_attempt == github.run_attempt || " + f"({out}.fleet_mode == 'fleet' && vars.{v} == 'fleet')) && " + f"{r}[1] && fromJSON(format('[\"{{0}}\",\"{{1}}-a{{2}}\"]', {r}[0], {r}[1], github.run_attempt)) " + f"|| '{HOSTED}' }}}}") + + def test_every_routed_job_uses_the_route(self): + jobs = self.tests['jobs'] + for j in CFG['jobs']: + with self.subTest(j['job']): + spec = jobs[j['job']] + self.assertEqual(spec['runs-on'], self.expected_runs_on(j)) + self.assertEqual(spec['needs'], 'changes') + shards = (spec.get('strategy') or {}).get('matrix', {}).get('shard') + self.assertEqual(len(shards) if shards else None, j.get('shards')) + + @staticmethod + def evaluate(expr, variables, outputs, attempt, shard=None): + """A runs-on expression in Python: GitHub's && and || return an operand, as Python's and/or do.""" + body = expr.strip()[3:-2] + for a, b in ((r'\bvars\.(\w+)', r'V.get("\1", "")'), (r'\bneeds\.changes\.outputs\.(\w+)', r'O.get("\1", "")'), + (r'\bgithub\.run_attempt\b', 'A'), (r'\bmatrix\.shard\b', 'S'), (r'\bfromJSON\(', 'J('), + (r'\bformat\(', 'F('), ('&&', ' and '), (r'\|\|', ' or ')): + body = re.sub(a, b, body) + + class Null: + """GitHub's null: falsy, and indexing it (or anything but an object or array) gives null again.""" + def __getitem__(self, key): + return self + + def __bool__(self): + return False + null = Null() + + class Obj(dict): + def __missing__(self, key): + return null + + class Arr(list): + def __getitem__(self, i): + return list.__getitem__(self, i) if isinstance(i, int) and 0 <= i < len(self) else null + + class Str(str): + def __getitem__(self, i): + return null + + def wrap(v): + if isinstance(v, dict): + return Obj({k: wrap(x) for k, x in v.items()}) + if isinstance(v, list): + return Arr(wrap(x) for x in v) + return Str(v) if isinstance(v, str) else null if v is None else v + text = lambda a: '' if a is null else str(a) # format(): null is '' + got = eval(body, {'__builtins__': {}}, {'V': variables, 'O': outputs, 'A': attempt, 'S': shard, # noqa: S307 + 'J': lambda s: wrap(json.loads(s)), + 'F': lambda f, *a: f.format(*map(text, a))}) + return json.loads(json.dumps(got)) + + def test_runs_on_follows_the_setting_on_a_re_run(self): + """s2 minor 1: a re-run of failed jobs keeps `changes`' outputs from the first attempt.""" + lint = self.tests['jobs']['lint']['runs-on'] + routed = lambda mode, attempt='1': {'runners': json.dumps({'lint': labels(WAITING, 'lint')}), 'fleet_mode': mode, + 'fleet_attempt': attempt} + cases = { # name: (vars, changes outputs, run attempt, runs on the fleet) + 'the attempt route ran in': ({}, routed('auto'), '1', True), + 'the setting is github now': ({'CI_RUNNERS': 'github'}, routed('auto'), '1', False), + 'auto, a re-run of failed jobs': ({}, routed('auto'), '2', False), + 'auto, a re-run of all jobs (route ran again)': ({}, routed('auto', '2'), '2', True), + 'fleet, a re-run while still fleet': ({'CI_RUNNERS': 'fleet'}, routed('fleet'), '2', True), + 'fleet, a re-run after a switch to auto': ({'CI_RUNNERS': 'auto'}, routed('fleet'), '2', False), + 'fleet, a re-run after a switch to github': ({'CI_RUNNERS': 'github'}, routed('fleet'), '2', False), + 'fleet, a re-run after the setting was removed': ({}, routed('fleet'), '2', False), + 'a release run (no route)': ({}, {}, '1', False), + 'route sent the job nowhere': ({}, dict(routed('auto'), runners='{}'), '1', False), + 'route made the job GitHub-hosted': ({}, dict(routed('auto'), runners=json.dumps({'lint': HOSTED})), '1', + False), + } + for name, (variables, outputs, attempt, on_fleet) in cases.items(): + with self.subTest(name): + got = self.evaluate(lint, variables, outputs, attempt) + self.assertEqual(got, job_labels(WAITING, 'lint', attempt=int(attempt)) if on_fleet else HOSTED) + if on_fleet: # what the dispatcher reads back: this attempt's job, and no other attempt's + self.assertEqual(ff.fleet_job(CFG, got), (POOLS[WAITING], 'lint', int(attempt))) + test = self.tests['jobs']['test']['runs-on'] + out = {'runners': json.dumps({'test-2': labels(WAITING, 'test-2'), 'test-1': HOSTED}), 'fleet_mode': 'auto', + 'fleet_attempt': '1'} + self.assertEqual(self.evaluate(test, {}, out, '1', shard=2), job_labels(WAITING, 'test-2')) + self.assertEqual(self.evaluate(test, {}, out, '1', shard=1), HOSTED) + self.assertEqual(self.evaluate(test, {}, out, '1', shard=3), HOSTED) + + def test_no_other_job_can_reach_the_fleet(self): + routed = {j['job'] for j in CFG['jobs']} + for name, spec in self.tests['jobs'].items(): + if name in routed: + continue + with self.subTest(name): + self.assertIsInstance(spec['runs-on'], str) + self.assertTrue(spec['runs-on'] == HOSTED or spec['runs-on'].startswith('macos-'), spec['runs-on']) + self.assertEqual(self.sweep['jobs']['sweep']['runs-on'], HOSTED) + + def test_the_upstream_sync_runs_on_github_hosted_runners_only(self): + """Owner decision 2026-11-12: the upstream sync (fork-follow.yml, fork-checks.yml) and the sweep run on + GitHub-hosted runners only, whatever CI_RUNNERS says: a literal hosted label, no expression, no fleet or + self-hosted label. The sync's release call passes a ref, so fork-tests' route never runs there (every routed + job is then GitHub-hosted: test_runs_on_follows_the_setting_on_a_re_run, 'a release run').""" + hosted = re.compile(r'(ubuntu|windows|macos)-(latest|[0-9]+(\.[0-9]+)?)(-arm)?') + never = {'self-hosted'} | {p['label'] for p in CFG['pools']} + for name, workflow in (('fork-follow.yml', self.follow), ('fork-checks.yml', self.checks), + ('fork-fleet.yml', self.sweep)): + for job, spec in workflow['jobs'].items(): + with self.subTest(workflow=name, job=job): + if 'uses' in spec: + self.assertEqual((name, spec['uses']), ('fork-follow.yml', './.github/workflows/fork-release.yml')) + self.assertTrue(str(spec['with'].get('ref') or '').strip()) + continue + runs_on = spec.get('runs-on') + self.assertIsInstance(runs_on, str) + self.assertNotIn('${{', runs_on) + self.assertTrue(re.fullmatch(hosted, runs_on), runs_on) + self.assertNotIn(runs_on.lower(), never) + self.assertEqual(self.release['jobs']['tests']['uses'], './.github/workflows/fork-tests.yml') + self.assertEqual(self.release['jobs']['tests']['with']['ref'], '${{ needs.preflight.outputs.ref }}') + self.assertEqual(self.tests['jobs']['changes']['steps'][-1]['if'], "inputs.ref == ''") + + def test_the_launcher_runs_as_root_in_ci(self): + """LauncherAsRoot needs root and real users: the workflow lint job (GitHub-hosted, throwaway) runs it.""" + job = self.tests['jobs']['workflows'] + self.assertEqual(job['runs-on'], HOSTED) + runs = [st.get('run') for st in job['steps']] + root = runs.index('sudo env FORK_FLEET_ROOT_TESTS=1 python3 -B .github/scripts/fork-fleet.test.py LauncherAsRoot') + # check-net's test needs the real nft, which the hosted image may lack: installed in a step before it. + self.assertTrue(any('apt-get install -y -qq nftables' in (r or '') for r in runs[:root]), runs) + + def test_the_route_step(self): + changes = self.tests['jobs']['changes'] + step = changes['steps'][-1] + self.assertEqual((step['id'], step['if'], step['run']), + ('route', "inputs.ref == ''", 'python3 -I .github/scripts/fork-fleet.py route')) + want = {'FLEET_MODE': f'${{{{ vars.{CFG["variable"]} }}}}', + 'GATES': '${{ toJSON(steps.detect.outputs) }}'} + want.update({p['beacon']: f'${{{{ vars.{p["beacon"]} }}}}' for p in CFG['pools']}) + self.assertEqual(step['env'], want) + self.assertEqual({k: v for k, v in changes['outputs'].items() if 'route' in v}, { + 'runners': '${{ steps.route.outputs.runners }}', 'fleet_mode': '${{ steps.route.outputs.mode }}', + 'fleet_attempt': '${{ steps.route.outputs.attempt }}', + 'fleet_watch': "${{ steps.route.outputs.watch || 'false' }}"}) + sparse = changes['steps'][0]['with']['sparse-checkout'] + self.assertIn('/.github/fork-fleet.json', sparse) + self.assertIn('/.github/scripts/fork-fleet.py', sparse) + self.assertNotIn('route', self.tests['jobs']) # folded into changes (s2 minor 2) + + def test_no_variable_or_secret_leaks_into_the_route(self): + import yaml + self.assertNotRegex(self.text, r'toJSON\(\s*vars\s*\)') # would print every variable to the log + self.assertNotIn('secrets.', yaml.safe_dump(self.tests['jobs']['changes'])) + self.assertNotIn('secrets.', yaml.safe_dump(self.tests['jobs']['fleet_watch'])) + + def test_the_watch_job(self): + watch = self.tests['jobs']['fleet_watch'] + self.assertEqual(watch['needs'], 'changes') + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", watch['if']) + self.assertIn("needs.changes.outputs.fleet_watch == 'true'", watch['if']) + self.assertEqual(watch['runs-on'], HOSTED) + self.assertEqual(watch['permissions'], {'actions': 'write', 'contents': 'read'}) + self.assertGreaterEqual(watch['timeout-minutes'] * 60, CFG['fleet_stuck_seconds'] + ff.SHOW_UP + 60) + step = watch['steps'][-1] + self.assertEqual(step['run'], 'python3 -I .github/scripts/fork-fleet.py watch') + self.assertEqual(step['env'], {'FLEET_MODE': '${{ needs.changes.outputs.fleet_mode }}', + 'FLEET_RUNNERS': '${{ needs.changes.outputs.runners }}', + 'GITHUB_TOKEN': '${{ github.token }}'}) + self.assertIn('fleet_watch', self.tests['jobs']['fork_ci_result']['needs']) + + def test_the_sweep(self): + job = self.sweep['jobs']['sweep'] + self.assertEqual(job['permissions'], {'actions': 'write', 'contents': 'read', 'pull-requests': 'read'}) + self.assertEqual(job['steps'][-1]['env'], {'FLEET_MODE': f'${{{{ vars.{CFG["variable"]} }}}}', + 'GITHUB_TOKEN': '${{ github.token }}'}) + self.assertEqual(job['steps'][-1]['run'], 'python3 -I .github/scripts/fork-fleet.py sweep') + + def test_the_release_caller_grants_what_fork_tests_asks(self): + """A called workflow fails to start when a job, even a skipped one, asks for more than the caller grants.""" + level = {'none': 0, 'read': 1, 'write': 2} + top = self.tests.get('permissions') or {} + granted = self.release['jobs']['tests'].get('permissions') + self.assertIsInstance(granted, dict) + for name, job in self.tests['jobs'].items(): + for scope, asked in (job.get('permissions', top) or {}).items(): + with self.subTest(job=name, scope=scope): + self.assertGreaterEqual(level[granted.get(scope, 'none')], level[asked]) + + def test_no_routed_job_reads_a_secret(self): + """A fleet job runs on our machines: it gets no secret.""" + import yaml + for j in CFG['jobs']: + with self.subTest(j['job']): + self.assertNotIn('secrets.', yaml.safe_dump(self.tests['jobs'][j['job']])) + + def test_no_routed_job_uses_sudo_on_the_fleet(self): + """A fleet runner has no sudo: every sudo in a routed job is behind a GitHub-hosted check.""" + for j in CFG['jobs']: + for step in self.tests['jobs'][j['job']]['steps']: + script = step.get('run') or '' + if 'sudo ' not in script: + continue + with self.subTest(job=j['job'], step=step.get('name')): + self.assertIn('if [[ "$RUNNER_ENVIRONMENT" == self-hosted ]]', script) + self.assertLess(script.index('RUNNER_ENVIRONMENT'), script.index('sudo ')) + + def test_the_hook_runs_the_installed_gate_isolated(self): + self.assertIn('exec /usr/bin/python3 -I "$(dirname -- "$0")/fork-fleet.py" ' + '--config "$(dirname -- "$0")/fork-fleet.json" gate', self.hook) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/fork-follow-push.sh b/.github/scripts/fork-follow-push.sh index ebbdc9040e704..e35ae1a06078a 100755 --- a/.github/scripts/fork-follow-push.sh +++ b/.github/scripts/fork-follow-push.sh @@ -6,8 +6,11 @@ # check Fails unless FORK_PUSH_TOKEN is set, accepted by GitHub and may push here. # candidate SHA Force-pushes SHA as the branch fork-follow/candidate. # advance PREV SHA Moves SERIES_BRANCH from PREV to SHA with a lease. First keeps PREV as the tag -# fork-follow/pre--, so the old series stays reachable. -# Already at SHA (a re-run) is success; moved elsewhere is a refusal. +# fork-follow/pre--, so the old series stays reachable, +# then tags SHA as fork-follow/synced/: the push run of a tagged commit never +# uses the fleet (upstream code runs GitHub-hosted only; tag_prefix in +# .github/fork-fleet.json, which must say the same). Already at SHA (a re-run) tags +# it and is success; moved elsewhere is a refusal. # cleanup Deletes the branch fork-follow/candidate; a failure is only a warning. # # Environment: FORK_PUSH_TOKEN, FORK_PUSH_ENVIRONMENT (the GitHub Environment that should hold the @@ -25,6 +28,7 @@ SERIES_BRANCH="${SERIES_BRANCH:-main}" REPORT="${REPORT:-fork-follow-report.md}" OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" CANDIDATE_BRANCH=fork-follow/candidate +SYNCED_TAG_PREFIX=fork-follow/synced/ err="$(mktemp)" trap 'rm -f "$err"' EXIT @@ -83,6 +87,12 @@ cmd_candidate() { echo "Pushed ${sha:0:12} to $CANDIDATE_BRANCH." } +tag_synced() { # tag_synced SHA OUTCOME: the tag keeping the push run of SHA off the fleet, made before main is SHA + git push "$PUSH_URL" "$1:refs/tags/$SYNCED_TAG_PREFIX$1" > /dev/null 2> "$err" \ + || die "Could not tag ${1:0:12} as $SYNCED_TAG_PREFIX$1, $2." \ + "Without the tag, the push run of a synced commit may use the fleet; upstream code runs on GitHub-hosted runners only. git said:" "$(git_said)" +} + cmd_advance() { local prev="${1:?PREV}" sha="${2:?SHA}" now backup : "${BASE_TAG:?BASE_TAG}" @@ -90,6 +100,7 @@ cmd_advance() { now="$(remote_ref "refs/heads/$SERIES_BRANCH")" \ || die "Could not read $SERIES_BRANCH of $REPOSITORY, so it was not moved. git said:" "$(git_said)" if [[ "$now" == "$sha" ]]; then + tag_synced "$sha" "so a re-run of its push run could use the fleet" # an earlier run moved it echo "moved=already" >> "$OUTPUT" echo "$SERIES_BRANCH is already at ${sha:0:12}." return 0 @@ -101,6 +112,7 @@ cmd_advance() { git push "$PUSH_URL" "$prev:refs/tags/$backup" > /dev/null 2> "$err" \ || die "Could not keep the old series ${prev:0:12} as tag $backup, so $SERIES_BRANCH was not moved. git said:" "$(git_said)" echo "backup_tag=$backup" >> "$OUTPUT" + tag_synced "$sha" "so $SERIES_BRANCH was not moved" git push --force-with-lease="refs/heads/$SERIES_BRANCH:$prev" "$PUSH_URL" "$sha:refs/heads/$SERIES_BRANCH" > /dev/null 2> "$err" \ || die "Could not move $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12}." \ "If a rule protects $SERIES_BRANCH, the token's user must be allowed to bypass it. The old series is kept as tag $backup. git said:" "$(git_said)" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 828e9be2e231f..4b31efb2c5036 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -698,11 +698,25 @@ check "push: a refused candidate push fails with git's reason" \ rm -f "$hook" push candidate "$next" check "push: the candidate goes to fork-follow/candidate" eval '[[ "$(series fork-follow/candidate)" == "$next" ]] && has candidate_pushed=true "$root/push.out"' +# The synced tag keeps the push run of the synced commit off the fleet (fork-fleet.py made_by_the_sync), so +# main may only move once it exists: a refused tag push leaves main where it was. +synced_tag() { git -C "$root/gh/me/t3code.git" rev-parse -q --verify "refs/tags/fork-follow/synced/$1" 2> /dev/null; } +printf '#!/bin/sh\nwhile read -r old new ref; do case "$ref" in refs/tags/fork-follow/synced/*) echo "no synced tags" >&2; exit 1;; esac; done\n' > "$hook" && chmod +x "$hook" +rm -f "$root/report.md" +check "push: advance refuses to move the series when the synced tag cannot be pushed" \ + eval '! push advance "$prev" "$next" && [[ "$(series)" == "$prev" ]] && has "Could not tag ${next:0:12} as fork-follow/synced/$next, so main was not moved" "$root/report.md" && has "no synced tags" "$root/report.md"' +printf '#!/bin/sh\nwhile read -r old new ref; do echo "$ref" >> "%s"; done\n' "$root/pushed-refs" > "$hook" +rm -f "$root/pushed-refs" push advance "$prev" "$next" check "push: advance moves the series and keeps the old one as a tag" \ eval '[[ "$(series)" == "$next" && "$(git -C "$root/gh/me/t3code.git" rev-parse "refs/tags/fork-follow/pre-v0.1.10-${prev:0:12}")" == "$prev" ]]' +check "push: advance tags the new series fork-follow/synced/ before it moves the series" \ + eval '[[ "$(synced_tag "$next")" == "$next" ]] && [[ "$(grep -n "" "$root/pushed-refs" | grep -E "refs/tags/fork-follow/synced/$next\$|refs/heads/main\$" | cut -d: -f2 | tr "\n" " ")" == "refs/tags/fork-follow/synced/$next refs/heads/main " ]]' +rm -f "$hook" +git -C "$root/gh/me/t3code.git" tag -d "fork-follow/synced/$next" > /dev/null push advance "$prev" "$next" check "push: advance again after it moved (re-run) succeeds without a push" has moved=already "$root/push.out" +check "push: advance again after it moved (re-run) makes a missing synced tag" eval '[[ "$(synced_tag "$next")" == "$next" ]]' work git commit -q --allow-empty -m "fork: merged meanwhile" work git push -q origin HEAD:main moved="$(series)" diff --git a/.github/workflows/fork-fleet.yml b/.github/workflows/fork-fleet.yml new file mode 100644 index 0000000000000..e0091ca159286 --- /dev/null +++ b/.github/workflows/fork-fleet.yml @@ -0,0 +1,55 @@ +name: Fork fleet sweep + +# Fork only. In auto and github mode (repository variable CI_RUNNERS; unset is +# auto), a trusted Fork tests run that the fleet did not finish runs again, +# GitHub-hosted: +# - a fleet job queued 10 minutes (stuck_seconds) without a runner: the run is +# cancelled (if fork-tests.yml's fleet_watch has not done it already) and +# re-run; +# - a run that ended red because a fleet runner stopped during a job (no failed +# step of the job's own): re-run. A run a person cancelled stays cancelled. +# Only trusted runs are touched, by the same rule as the dispatcher's (GitHub's +# record of the run, each pull request read from the API), never a stranger's. +# The sweep's re-run is triggered by github-actions, which is not a trusted +# actor: it is GitHub-hosted, and the sweep never touches it, so each run is +# re-run once at most. Nothing is kept between sweeps: each reads the runs of +# the last 24 hours (the latest attempt of each) from GitHub, so a sweep that +# stopped half-way is finished by the next. Fleet mode re-runs nothing: it only +# cancels, with an error, a trusted run whose fleet job waited 2 hours +# (fleet_stuck_seconds; a re-run of failed jobs has no fleet_watch of its own). +# .github/scripts/fork-fleet.py sweep; runbook .github/fork-fleet.md. +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fork-fleet-sweep + cancel-in-progress: false + +jobs: + sweep: + name: Sweep + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: write + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + sparse-checkout: | + /.github/fork-fleet.json + /.github/scripts/fork-fleet.py + sparse-checkout-cone-mode: false + + - name: Cancel and re-run what the fleet did not finish + env: + FLEET_MODE: ${{ vars.CI_RUNNERS }} + GITHUB_TOKEN: ${{ github.token }} + run: python3 -I .github/scripts/fork-fleet.py sweep diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index d33953692ec2f..4163540a18ec5 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -150,6 +150,13 @@ jobs: tests: name: Tests needs: [preflight] + # A called workflow's job may not ask for more than its caller grants, even a job that is + # skipped: fork-tests.yml's fleet_watch asks for actions: write (it cancels a run stuck waiting + # for the fleet; never in a release run, which is GitHub-hosted). fork-tests.yml's own + # top-level permissions keep its other jobs at contents: read. + permissions: + actions: write + contents: read uses: ./.github/workflows/fork-tests.yml with: ref: ${{ needs.preflight.outputs.ref }} diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml index cb7b1ad104668..4ad2eea9012f8 100644 --- a/.github/workflows/fork-tests.yml +++ b/.github/workflows/fork-tests.yml @@ -14,10 +14,21 @@ name: Fork tests # Require only `fork-ci-result` in a ruleset: it needs every other job, so jobs # can be added, split or resharded without touching the ruleset. # -# Runners: GitHub-hosted only, Ubuntu 24.04 like upstream's runners, so an OS -# upgrade is a reviewed change here, not a surprise from ubuntu-latest. Never -# add a self-hosted runner to this workflow: this repository is public, so a -# pull request from any fork would run its code on that machine. +# Runners: the `route` step of `changes` picks, per Linux job, this +# repository's own fleet or GitHub-hosted Ubuntu 24.04 (like upstream's runners, +# so an OS upgrade is a reviewed change here, not a surprise from +# ubuntu-latest). The setting is the repository variable CI_RUNNERS: auto +# (unset too), fleet or github; facts about the fleet are in +# .github/fork-fleet.json, the runbook in .github/fork-fleet.md. Only trusted +# runs may use the fleet (pushes to main and the owner's own pull requests from +# this repository): this repository is public, and a pull request from a fork +# must never run its code on a fleet machine. No fleet runner waits for work: a +# dispatcher on each fleet host reads GitHub's record of a run and starts a +# one-job runner only for a queued job of a trusted run that asks for the +# random label `route` made for it. So a changed workflow that names a fleet +# label finds no runner, and each runner's job-started hook kills any job but +# the one it was made for. The macOS job and the short jobs before and after the +# tests stay GitHub-hosted. # # Two modes: # - pull_request / push to main: CI. Jobs a change cannot affect are skipped by @@ -98,6 +109,11 @@ jobs: rust: ${{ steps.release.outputs.rust || steps.detect.outputs.rust }} release_smoke: ${{ steps.release.outputs.release_smoke || steps.detect.outputs.release_smoke }} mobile_native: ${{ steps.release.outputs.mobile_native || steps.fork.outputs.mobile_native }} + # Where each Linux job runs (the `route` step; empty in a release run, which is GitHub-hosted). + runners: ${{ steps.route.outputs.runners }} + fleet_mode: ${{ steps.route.outputs.mode }} + fleet_attempt: ${{ steps.route.outputs.attempt }} + fleet_watch: ${{ steps.route.outputs.watch || 'false' }} steps: - name: Checkout if: inputs.ref == '' @@ -110,6 +126,8 @@ jobs: /.github/actions/ci-changes/ /*/package.json /*/*/package.json + /.github/fork-fleet.json + /.github/scripts/fork-fleet.py !/.repos/ sparse-checkout-cone-mode: false @@ -165,6 +183,52 @@ jobs: echo "test_packages=" >> "$GITHUB_OUTPUT" echo "mobile_native=false" >> "$GITHUB_OUTPUT" + # Where each Linux job below runs (.github/scripts/fork-fleet.py route). It + # calls no API and never fails for the fleet: a pool counts only with a + # fresh heartbeat (a repository variable its dispatcher writes), anything + # unclear is GitHub-hosted with a note. A failed step fails `changes`; an + # empty answer means GitHub-hosted. A re-run of failed jobs keeps this + # answer only while the setting is still fleet; otherwise it is GitHub-hosted. + # Each pool's heartbeat is passed by name: a toJSON of all variables would print the rest. + - name: Route + id: route + if: inputs.ref == '' + env: + FLEET_MODE: ${{ vars.CI_RUNNERS }} + FLEET_BEACON_PC: ${{ vars.FLEET_BEACON_PC }} + FLEET_BEACON_VM: ${{ vars.FLEET_BEACON_VM }} + GATES: ${{ toJSON(steps.detect.outputs) }} + run: python3 -I .github/scripts/fork-fleet.py route + + # A fleet job that waits too long for its runner cancels the run with an + # error (.github/scripts/fork-fleet.py watch): 10 minutes in auto mode (the + # sweep then re-runs it GitHub-hosted), 120 in fleet mode. Only in a run + # that sent a job to the fleet. + fleet_watch: + name: Fleet watch + needs: changes + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (needs.changes.outputs.fleet_watch == 'true') }} + runs-on: ubuntu-24.04 + timeout-minutes: 125 + permissions: + actions: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + sparse-checkout: | + /.github/fork-fleet.json + /.github/scripts/fork-fleet.py + sparse-checkout-cone-mode: false + + - name: Cancel the run if a fleet job waits too long + env: + FLEET_MODE: ${{ needs.changes.outputs.fleet_mode }} + FLEET_RUNNERS: ${{ needs.changes.outputs.runners }} + GITHUB_TOKEN: ${{ github.token }} + run: python3 -I .github/scripts/fork-fleet.py watch + # A broken fork workflow is red on its own pull request, and the fork copy # must follow upstream's test jobs: the drift check lists every job and step # upstream changed since the copy was last recorded. @@ -212,6 +276,17 @@ jobs: fi node .github/scripts/fork-ci-tests.cjs + # The fleet launcher with real runner users (created and deleted by the test): + # one slot's user cannot read another's copy, nothing a job leaves (files in + # HOME, a running process) reaches the next job, root deletes nothing in a + # directory a runner user can write, and check-net proves a runner's network + # with real namespaces and the real nft table (hence nftables). + - name: Install nftables for the launcher's network test + run: command -v nft >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq nftables; } + + - name: Fleet launcher as root + run: sudo env FORK_FLEET_ROOT_TESTS=1 python3 -B .github/scripts/fork-fleet.test.py LauncherAsRoot + - name: Compare upstream's test jobs with the recorded copy run: node .github/scripts/fork-tests-drift.cjs @@ -265,9 +340,10 @@ jobs: lint: name: Lint - if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes - runs-on: ubuntu-24.04 + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')['lint'][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')['lint'][0], fromJSON(needs.changes.outputs.runners || '{}')['lint'][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 15 steps: - name: Checkout @@ -316,7 +392,8 @@ jobs: name: Build and Typecheck needs: changes if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false')) }} - runs-on: ubuntu-24.04 + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')['build'][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')['build'][0], fromJSON(needs.changes.outputs.runners || '{}')['build'][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout @@ -334,14 +411,24 @@ jobs: - name: Start installing browser secret helper build libraries if: needs.changes.outputs.build != 'false' run: | - ( - set +e - sudo apt-get update -o Acquire::Languages=none \ - -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ - -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && - sudo apt-get install -y libsecret-1-dev pkg-config xvfb - echo "$?" > "$RUNNER_TEMP/apt-status" - ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & + # A fleet runner has no sudo: its host must have the packages already. + if [[ "$RUNNER_ENVIRONMENT" == self-hosted ]]; then + if pkg-config --exists libsecret-1 && command -v xvfb-run >/dev/null; then + echo 0 > "$RUNNER_TEMP/apt-status" + else + echo "This fleet runner's host lacks some of: libsecret-1-dev pkg-config xvfb (.github/fork-fleet.md)" > "$RUNNER_TEMP/apt.log" + echo 1 > "$RUNNER_TEMP/apt-status" + fi + else + ( + set +e + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config xvfb + echo "$?" > "$RUNNER_TEMP/apt-status" + ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & + fi - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps @@ -465,7 +552,8 @@ jobs: name: Test ${{ matrix.shard }} needs: changes if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.test != 'false') }} - runs-on: ubuntu-24.04 + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')[format('test-{0}', matrix.shard)][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')[format('test-{0}', matrix.shard)][0], fromJSON(needs.changes.outputs.runners || '{}')[format('test-{0}', matrix.shard)][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 15 strategy: fail-fast: false @@ -498,14 +586,24 @@ jobs: - name: Start installing browser secret helper build libraries if: steps.shard.outputs.any == 'true' run: | - ( - set +e - sudo apt-get update -o Acquire::Languages=none \ - -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ - -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && - sudo apt-get install -y libsecret-1-dev pkg-config - echo "$?" > "$RUNNER_TEMP/apt-status" - ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & + # A fleet runner has no sudo: its host must have the packages already. + if [[ "$RUNNER_ENVIRONMENT" == self-hosted ]]; then + if pkg-config --exists libsecret-1; then + echo 0 > "$RUNNER_TEMP/apt-status" + else + echo "This fleet runner's host lacks some of: libsecret-1-dev pkg-config (.github/fork-fleet.md)" > "$RUNNER_TEMP/apt.log" + echo 1 > "$RUNNER_TEMP/apt-status" + fi + else + ( + set +e + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config + echo "$?" > "$RUNNER_TEMP/apt-status" + ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & + fi - name: Setup Vite+ if: steps.shard.outputs.any == 'true' @@ -582,7 +680,8 @@ jobs: name: Test Web ${{ matrix.shard }} needs: changes if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.test_web != 'false') }} - runs-on: ubuntu-24.04 + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')[format('test_web-{0}', matrix.shard)][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')[format('test_web-{0}', matrix.shard)][0], fromJSON(needs.changes.outputs.runners || '{}')[format('test_web-{0}', matrix.shard)][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 15 strategy: fail-fast: false @@ -645,9 +744,10 @@ jobs: # release run's resolved commit (seconds; every other job waits for it too). test_server: name: Test Server ${{ matrix.shard }} - if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes - runs-on: ubuntu-24.04 + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')[format('test_server-{0}', matrix.shard)][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')[format('test_server-{0}', matrix.shard)][0], fromJSON(needs.changes.outputs.runners || '{}')[format('test_server-{0}', matrix.shard)][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 20 strategy: fail-fast: false @@ -783,7 +883,8 @@ jobs: name: Rust needs: changes if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.rust != 'false') }} - runs-on: ubuntu-24.04 + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')['rust'][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')['rust'][0], fromJSON(needs.changes.outputs.runners || '{}')['rust'][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 10 steps: - name: Checkout @@ -850,7 +951,8 @@ jobs: name: Release Smoke needs: changes if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.release_smoke != 'false') }} - runs-on: ubuntu-24.04 + # Fleet or GitHub-hosted: the `route` step of `changes` (.github/fork-fleet.md). + runs-on: ${{ vars.CI_RUNNERS != 'github' && (needs.changes.outputs.fleet_attempt == github.run_attempt || (needs.changes.outputs.fleet_mode == 'fleet' && vars.CI_RUNNERS == 'fleet')) && fromJSON(needs.changes.outputs.runners || '{}')['release_smoke'][1] && fromJSON(format('["{0}","{1}-a{2}"]', fromJSON(needs.changes.outputs.runners || '{}')['release_smoke'][0], fromJSON(needs.changes.outputs.runners || '{}')['release_smoke'][1], github.run_attempt)) || 'ubuntu-24.04' }} timeout-minutes: 15 steps: - name: Checkout @@ -886,6 +988,7 @@ jobs: [ outside_pr, changes, + fleet_watch, workflows, secrets, lint, @@ -922,7 +1025,8 @@ jobs: else "no native mobile source, Brewfile or CI workflow changed" end), release_smoke: "the change does not affect scripts/", secrets: "release run: the commit was scanned on its way to main", - outside_pr: "not a pull request from another fork" + outside_pr: "not a pull request from another fork", + fleet_watch: "no job of this run went to the fleet" }' { echo "### fork-ci-result" From 17bbf2aa36999927b0e0c63be4ebbfedb49c94ab Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:03:45 +0100 Subject: [PATCH 16/27] fork-ci: job-changed composite action (did this workflow job's inputs change?) (#27) * fork-ci: job-changed composite action (did this workflow job's inputs change?) A workflow edit counts for a job only when its parsed block, a job it needs (transitively) or the workflow-level on/env/defaults/permissions changed, compared in a text and a typed view. Optional `paths` regexes count too. Fails closed (changed=true and why) on no base, a wrong workflow path, parse errors, a missing job, unknown top-level keys, git errors, no PyYAML. Writes nothing but step outputs: changed, workflow, reason. Co-Authored-By: Claude Opus 5.5 * fork-ci: job-changed test comment names no project Co-Authored-By: Claude Opus 5.5 * fork-ci: job-changed tests pin rename-free diff and .nan Co-Authored-By: Claude Opus 5.5 * job-changed: needs through a YAML merge key counts (lens r1 major) The text view (BaseLoader) keeps a `<<:` merge key as a key, so a job whose needs came through one missed the jobs it needs: an edit there read as unchanged. The needed jobs are now taken from both views. Tests: needs via an inline merge and via an anchored merge; an option-like base or head fails closed without reaching git. README: what only `paths` sees (local actions, scripts, a reusable workflow's caller) and quoting values where YAML 1.1 and GitHub may differ. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 1 + fork-ci/actions/job-changed/README.md | 136 ++++++ fork-ci/actions/job-changed/action.yml | 56 +++ .../actions/job-changed/job-changed.test.py | 462 ++++++++++++++++++ fork-ci/actions/job-changed/job_changed.py | 203 ++++++++ 5 files changed, 858 insertions(+) create mode 100644 fork-ci/actions/job-changed/README.md create mode 100644 fork-ci/actions/job-changed/action.yml create mode 100644 fork-ci/actions/job-changed/job-changed.test.py create mode 100644 fork-ci/actions/job-changed/job_changed.py diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 56e607f6ed3c0..caefbecbc100a 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -18,6 +18,7 @@ .github/scripts/fork-workflow-triggers.test.cjs fork-ci/actions/docs-scope/docs-scope.test.py fork-ci/actions/gradle-cache/gradle_cache.test.py +fork-ci/actions/job-changed/job-changed.test.py # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/fork-ci/actions/job-changed/README.md b/fork-ci/actions/job-changed/README.md new file mode 100644 index 0000000000000..e1f6a94f41ee9 --- /dev/null +++ b/fork-ci/actions/job-changed/README.md @@ -0,0 +1,136 @@ +# job-changed + +A composite action that answers one question for one job: **can the change from `base` to HEAD change what this +job does?** A workflow edit that only touches another job, a comment, a blank line, key order or quoting then does +not rebuild the job. + +It is the general half of a "did this job's inputs change" gate. The project half (which source folders a job +builds, which docs a build reads) stays in the calling repository: pass folders as `paths`, or gate them with +[docs-scope](../docs-scope/README.md). + +## Usage + +Pin the action by full SHA (the tagged one, so a rebase of this fork cannot orphan it): + +```yaml +jobs: + plugins: + steps: + - uses: actions/checkout@ # the default ref: on pull_request GitHub's merge commit + with: { fetch-depth: 0 } # the base must be in the checkout + - id: changed + uses: scuttletoby/t3code/fork-ci/actions/job-changed@ # ci/vN + with: + base: ${{ github.event_name == 'pull_request' && 'HEAD^1' || steps.green.outputs.sha }} + workflow: .github/workflows/ci.yml + # job: defaults to github.job + paths: | # optional: changed files that also count (Python regular expressions, re.search) + ^plugins/ + ^tools/build\.py$ + - if: steps.changed.outputs.changed != 'false' + run: ./gradlew build +``` + +Always test `!= 'false'`, never `== 'true'`: a missing or empty output (a step that failed, a runner without +Python) then means "run". + +Run it as a step of the job it gates, as above. If it runs in a job of its own that others `need`, a failure of that +job skips them, and GitHub counts a skipped required job as passing: gate them with +`if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.changed != 'false') }}`. + +### Inputs + +| Input | Default | Meaning | +| ---------- | ------------ | ------------------------------------------------------------------------------------ | +| `base` | (empty) | the commit to compare with; empty or not in the checkout means `changed=true` | +| `head` | `HEAD` | the commit to judge | +| `workflow` | (required) | the workflow file, a path in the repository | +| `job` | `github.job` | the job id (in a reusable workflow, the called workflow's file and job) | +| `paths` | (empty) | one regular expression per line; a changed file one finds counts (`#` lines skipped) | + +### Outputs + +| Output | Value | +| ---------- | ----------------------------------------------------------------------------------- | +| `changed` | `false` only when neither the workflow part nor a path counts | +| `workflow` | the workflow part alone: `false` only when the workflow change cannot reach the job | +| `reason` | one line: `changed: ...` or `unchanged: ...` and why | + +`workflow` is for a caller that already knows its paths and only needs the workflow question, for example a test +scope that widens to every test when the job's own definition changed (not when another job's did). + +### The base + +- **pull_request** (checkout of GitHub's merge commit): `HEAD^1`, the base branch as GitHub merged it. +- **push**: the last commit whose push run of this workflow passed (see docs-scope's README, "The base on push", + for the query), or nothing (always `changed`). Never `github.event.before`: if push A changed this job's block and + its run was cancelled or red, push B diffs against A, the job reads `false`, and A's change is never tested. + +## The rule + +The workflow file counts for the job when one of these differs between the two commits: + +- the job's own block; +- the block of every job it `needs`, directly or not (their outputs and results feed it); +- the workflow-level `on`, `env`, `defaults` or `permissions`; +- any other top-level key except `jobs`, `name`, `run-name` and `concurrency` (an unknown key could matter). + +Both sides are compared parsed, in two views, and a part counts when either view differs: + +- **text**: every scalar as written (YAML's BaseLoader), so `1` -> `true` or `0755` -> `493` counts; +- **types**: YAML's SafeLoader values compared strictly, so `3.10` -> `'3.10'` (the number 3.1 becomes a string) or + `1` -> `'1'` counts, and `1`, `True` and `1.0` are all different. + +Comments, blank lines, key order, flow vs block style and quoting that keeps the type (`'x'` -> `"x"` -> `x`) never +count. Aliases are resolved first, so an anchor edited in another job reaches every job that uses it. + +`paths` never matches the workflow file itself: only its job compare decides about it. + +**What only `paths` sees.** The action compares one workflow file. Anything else the job runs counts only when a +`paths` line finds it: local actions (`uses: ./...`), local reusable workflows it calls, and scripts or config files +its steps run. Inside a reusable workflow, edits in the calling workflow (its `with:`, `secrets:`, the calling job's +`if:` or `needs`) never reach the called file's compare: add the caller's file to `paths`, or run the check in the +calling workflow. + +**Typed values.** The typed view is PyYAML's YAML 1.1 reading. Where it and GitHub's reader may differ (for example +an unquoted `0o17`, which PyYAML reads as text and GitHub may read as a number), quote the value. + +## Fails closed + +`changed=true` (and `workflow=true`), with the reason in the log and in `reason`, for: an empty `base`, a base or +head that is not a commit in the checkout (first push, force push, a base that is gone, a shallow checkout), a +workflow path that is not at HEAD (a wrong path must never read as "never changed"), a side that does not parse in +either view (a syntax error, an alias without its anchor, a date like `2026-13-45` or an unknown `!tag`, nesting too +deep), a file that is not a workflow (no `jobs` mapping), a new or deleted workflow, the job or one it needs missing on +either side (renamed, new, deleted, or a typo in `job`), a `paths` line that is not a regular expression, a failed +`git diff`, and any other error. The step never ends without outputs. + +**No PyYAML on the runner:** the action installs nothing (an unpinned download at run time would be new code no +review saw). A changed workflow file then reads as `changed=true`, and the step prints a `::warning::` annotation +saying so; an unchanged workflow file needs no parsing and reads normally. Install `python3-yaml` on self-hosted +runners; GitHub's Ubuntu images have it. + +## Why a pull request cannot loosen it + +- The script runs from the action's own folder, at the SHA the caller pins: never a copy from the checkout. +- The workflow file is read from git objects (`git show :`), never from the working tree. +- Inputs reach the script through environment variables only, never `${{ }}` inside `run:`. +- Printed text escapes control characters, so a job or file name can neither start a `::workflow-command::` line nor + add an output line. + +What it cannot stop: a `pull_request` run uses the workflow file from the pull request, so a pull request that edits +the `uses:` line or the job's `if:` chooses what judges it. Such an edit changes the job's own block, so every correct +version reads it as `changed`. + +## State + +The action writes nothing but its step outputs: no cache, no file, no label. A rerun of the same commits gives the +same answer (on push, a rerun may see a newer green base, which only shrinks the diff to commits a passing run +tested). Stopping half-way leaves nothing behind. An old or empty history (first push, shallow clone, force push) +has no base and reads as `changed`. + +## Tests + +`job-changed.test.py` runs the action's real `run:` text with bash in real git repositories, plus the rule on this +repository's own workflows. It runs in the fork's CI (`.github/fork-ci-tests.txt`): +`python3 fork-ci/actions/job-changed/job-changed.test.py`. diff --git a/fork-ci/actions/job-changed/action.yml b/fork-ci/actions/job-changed/action.yml new file mode 100644 index 0000000000000..f35535c3cba8c --- /dev/null +++ b/fork-ci/actions/job-changed/action.yml @@ -0,0 +1,56 @@ +name: job-changed +description: >- + Can the change from base to HEAD change what one job of a workflow does? Compares the job's + parsed block, every job it needs and the workflow-level on/env/defaults/permissions; optional + path expressions count too. Anything unclear means changed. Usage: README.md next to this file. +inputs: + base: + description: >- + The commit to compare with (pull_request on GitHub's merge commit: HEAD^1; push: the last + commit whose push run passed, see README). Empty or not in the checkout: changed. + required: false + default: "" + head: + description: The commit to judge (default HEAD, the checkout). + required: false + default: HEAD + workflow: + description: The workflow file, a path in the repository (e.g. .github/workflows/ci.yml). + required: true + job: + description: The job id whose part of the workflow counts. + required: false + default: ${{ github.job }} + paths: + description: >- + Optional. One Python regular expression per line (re.search on each changed path, # comments + allowed); a changed file one finds makes the job changed. The workflow file is never matched + here: only its job compare counts. + required: false + default: "" +outputs: + changed: + description: "'false' only when neither the workflow part nor a path counts; test != 'false'." + value: ${{ steps.changed.outputs.changed }} + workflow: + description: "The workflow part alone: 'false' only when the workflow change cannot reach the job." + value: ${{ steps.changed.outputs.workflow }} + reason: + description: One line saying why. + value: ${{ steps.changed.outputs.reason }} +runs: + using: composite + steps: + # Only code from this action's folder (the caller's pinned SHA) runs, and the workflow is read + # from git objects (git show :), never from the working tree. + - id: changed + shell: bash + env: + BASE: ${{ inputs.base }} + HEAD: ${{ inputs.head }} + WORKFLOW: ${{ inputs.workflow }} + JOB: ${{ inputs.job }} + PATHS: ${{ inputs.paths }} + run: >- + python3 -I "$GITHUB_ACTION_PATH/job_changed.py" --base="$BASE" --head="$HEAD" + --workflow="$WORKFLOW" --job="$JOB" --paths="$PATHS" diff --git a/fork-ci/actions/job-changed/job-changed.test.py b/fork-ci/actions/job-changed/job-changed.test.py new file mode 100644 index 0000000000000..8254acf68e98e --- /dev/null +++ b/fork-ci/actions/job-changed/job-changed.test.py @@ -0,0 +1,462 @@ +"""job_changed.py and its action: a workflow change counts for a job only when its parsed block, a job it needs or the +workflow-level on/env/defaults/permissions changed; anything unreadable or unclear means changed. +Ported from a project's tests of the same rule. Every case runs the action's real `run:` text with bash in a real git +repo (inputs passed as the action passes them, by env), the way the runner does, and reads $GITHUB_OUTPUT. +Run: python3 fork-ci/actions/job-changed/job-changed.test.py +""" +import contextlib, io, os, re, shutil, subprocess, sys, tempfile, unittest, unittest.mock + +import yaml + +sys.dont_write_bytecode = True # the tests import job_changed.py: leave no __pycache__ in the action folder + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, '..', '..', '..')) +ACTION = os.path.join(HERE, 'action.yml') +sys.path.insert(0, HERE) +import job_changed # noqa: E402 + +# A caller's GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE ... would point every git call here (and the step's) at another repo. +for _k in [k for k in os.environ if k.startswith('GIT_')]: + del os.environ[_k] + +ENV = {'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@t', 'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@t', + 'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1'} +JOBS = ('build', 'plugins') +CI = '.github/workflows/ci.yml' +# A workflow with a route job the two heavy jobs need, and a job they do not need. +FIXTURE = """# a workflow +name: ci +on: + pull_request: { branches: [main] } +concurrency: { group: ci-x } +permissions: { contents: read } +env: + BASE: x +jobs: + route: + runs-on: [self-hosted, light] + outputs: { plugins: "${{ steps.r.outputs.plugins }}" } + steps: + - run: python3 route.py + build: + needs: route + runs-on: [self-hosted, build] + steps: + - run: ./gradlew build + plugins: + needs: [route] + runs-on: [self-hosted, build] + steps: &steps + - name: build + run: ./gradlew build ciJars + tooling: + runs-on: [self-hosted, build] + steps: + - run: python3 tests.py +""" +NONE, BOTH = {'build': 'false', 'plugins': 'false'}, {'build': 'true', 'plugins': 'true'} + + +def step(): + """(env, run) of the action's one step.""" + with open(ACTION) as f: + steps = yaml.safe_load(f)['runs']['steps'] + assert len(steps) == 1 + return steps[0]['env'], steps[0]['run'] + + +class Repo(unittest.TestCase): + def setUp(self): + self.d = tempfile.mkdtemp(prefix='job-changed-') + self.addCleanup(shutil.rmtree, self.d, True) + self.git('init', '-q', '-b', 'main') + self.base = self.commit({CI: FIXTURE, 'src/A.java': 'class A {}', 'docs/README.md': 'a'}) + + def git(self, *a): + r = subprocess.run(['git', *a], cwd=self.d, text=True, capture_output=True, env={**os.environ, **ENV}) + if r.returncode: + raise RuntimeError(f'git {a}: {r.stderr}') + return r.stdout.strip() + + def commit(self, files): + for path, text in files.items(): + full = os.path.join(self.d, path) + if text is None: + os.remove(full) + continue + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, 'w') as f: + f.write(text) + self.git('add', '-A') + self.git('commit', '-q', '--allow-empty', '-m', 'c') + return self.git('rev-parse', 'HEAD') + + def edit(self, old, new): + assert old in FIXTURE, old + self.commit({CI: FIXTURE.replace(old, new, 1)}) + + def outputs(self, job, base=None, **inputs): + """{name: value} the action writes to $GITHUB_OUTPUT, its run text run with bash -e as the runner runs it.""" + out = os.path.join(self.d, '.output') + open(out, 'w').close() + env, run = step() + given = {'base': self.base if base is None else base, 'head': 'HEAD', 'workflow': CI, 'job': job, 'paths': '', + **inputs} + values = {k: given[re.fullmatch(r'\$\{\{ inputs\.(\w+) \}\}', v)[1]] for k, v in env.items()} + r = subprocess.run(['bash', '--noprofile', '--norc', '-e', '-c', run], cwd=self.d, text=True, + capture_output=True, env={**os.environ, **ENV, **values, 'GITHUB_OUTPUT': out, + 'GITHUB_ACTION_PATH': HERE}) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.log = r.stdout + with open(out) as f: + return dict(line.split('=', 1) for line in f.read().splitlines() if '=' in line) + + def runs(self, job, base=None, **inputs): + return self.outputs(job, base, **inputs).get('changed') + + def builds(self): + return {j: self.runs(j) for j in JOBS} + + +class Workflow(Repo): + def test_a_comment_or_blank_line_builds_nothing(self): + self.edit('# a workflow\n', '# a workflow, now explained\n\n# more\n') + self.assertEqual(self.builds(), NONE) + + def test_the_same_file_builds_nothing(self): + self.commit({'src/A.java': 'class A { int x; }'}) + self.assertEqual(self.outputs('plugins'), {'changed': 'false', 'workflow': 'false', + 'reason': f'unchanged: {CI} is the same at both commits'}) + + def test_a_job_they_do_not_need_builds_nothing(self): + self.edit('python3 tests.py', 'python3 tests.py --fast') + self.assertEqual(self.builds(), NONE) + self.assertEqual(self.runs('tooling'), 'true') + + def test_a_pr_that_only_deletes_another_jobs_lines_builds_nothing(self): + self.edit(' tooling:\n runs-on: [self-hosted, build]\n steps:\n - run: python3 tests.py\n', '') + self.assertEqual(self.builds(), NONE) + + def test_quoting_flow_style_and_key_order_build_nothing(self): + self.edit(' needs: [route]\n runs-on: [self-hosted, build]\n', + ' runs-on:\n - "self-hosted"\n - build\n needs:\n - route\n') + self.assertEqual(self.builds(), NONE) + + def test_name_and_concurrency_build_nothing(self): + self.commit({CI: FIXTURE.replace('name: ci', 'name: CI').replace('group: ci-x', 'group: ci-y')}) + self.assertEqual(self.builds(), NONE) + + def test_its_own_block_builds_only_that_job(self): + self.edit('ciJars', 'ciJars productionBuild') + self.assertEqual(self.builds(), {'build': 'false', 'plugins': 'true'}) + self.assertIn('job plugins changed', self.outputs('plugins')['reason']) + self.edit(' runs-on: [self-hosted, build]\n steps:\n - run: ./gradlew build\n', + ' runs-on: [self-hosted, pc]\n steps:\n - run: ./gradlew build\n') + self.assertEqual(self.builds(), {'build': 'true', 'plugins': 'false'}) + + def test_the_route_job_they_need_builds_both(self): + self.edit('python3 route.py', 'python3 route.py --pc') + self.assertEqual(self.builds(), BOTH) + self.assertIn('job route (which plugins needs) changed', self.outputs('plugins')['reason']) + + def test_a_job_needed_indirectly_builds(self): + self.commit({CI: FIXTURE.replace(' route:\n', ' setup:\n runs-on: x\n steps: [{ run: a }]\n' + ' route:\n needs: setup\n', 1)}) + self.base = self.git('rev-parse', 'HEAD') + self.commit({CI: FIXTURE.replace(' route:\n', ' setup:\n runs-on: x\n steps: [{ run: b }]\n' + ' route:\n needs: setup\n', 1)}) + self.assertEqual(self.builds(), BOTH) + self.assertEqual(self.runs('tooling'), 'false') + + def test_needs_through_a_merge_key_builds(self): # the text view keeps `<<` as a key: only the typed view sees it + plain = (' build:\n needs: route\n', ' plugins:\n needs: [route]\n') + for build, plugins in (((' build:\n <<: { needs: route }\n', plain[1])), + (' build:\n <<: &needroute { needs: route }\n', ' plugins:\n <<: *needroute\n')): + with self.subTest(build=build, plugins=plugins): + text = FIXTURE.replace(plain[0], build, 1).replace(plain[1], plugins, 1) + self.base = self.commit({CI: text}) + self.commit({CI: text.replace('python3 route.py', 'python3 route.py --pc')}) + self.assertEqual(self.builds(), BOTH) + self.assertEqual(self.runs('tooling'), 'false') + + def test_workflow_env_permissions_and_on_build_both(self): + for old, new in (('BASE: x', 'BASE: y'), ('contents: read', 'contents: write'), + ('branches: [main]', 'branches: [main, next]'), + ('env:\n BASE: x\n', 'defaults: { run: { shell: sh } }\nenv:\n BASE: x\n')): + with self.subTest(new=new): + self.edit(old, new) + self.assertEqual(self.builds(), BOTH) + + def test_an_unknown_top_level_key_builds_both(self): + self.edit('name: ci\n', 'name: ci\nsomething-new: { x: 1 }\n') + self.assertEqual(self.builds(), BOTH) + + def test_a_parse_error_builds_both(self): + self.edit(' steps: &steps\n', ' steps: &steps\n bad: [\n') + self.assertEqual(self.builds(), BOTH) + + def test_not_a_workflow_builds_both(self): + for text in ('just text\n', '- a list\n', 'jobs: [a]\n', ''): + with self.subTest(text=text): + self.commit({CI: text}) + self.assertEqual(self.builds(), BOTH) + + def test_an_alias_without_its_anchor_builds_both(self): + self.edit(' steps:\n - run: python3 tests.py\n', ' steps: *nowhere\n') + self.assertEqual(self.builds(), BOTH) + + def test_an_anchor_edit_in_the_job_that_defines_it_builds_that_job(self): + self.edit(' steps:\n - run: python3 tests.py\n', ' steps: *steps\n') # tooling uses plugins' steps + self.assertEqual(self.builds(), NONE) + self.edit(' steps: &steps\n - name: build\n', ' steps: &steps\n - name: build all\n') + self.assertEqual(self.builds(), {'build': 'false', 'plugins': 'true'}) + + def test_an_anchor_change_reaches_the_job_that_uses_it(self): + # The anchor lives in a job neither heavy job needs (lint); plugins only uses it through an alias. + lint = (' lint:\n runs-on: [self-hosted, light]\n env: &gradle { GRADLE_OPTS: "-Xmx2g" }\n' + ' steps:\n - run: lint\n plugins:\n env: *gradle\n') + self.base = self.commit({CI: FIXTURE.replace(' plugins:\n', lint, 1)}) + self.commit({CI: FIXTURE.replace(' plugins:\n', lint.replace('-Xmx2g', '-Xmx4g'), 1)}) + self.assertEqual(self.builds(), {'build': 'false', 'plugins': 'true'}) + self.base = self.git('rev-parse', 'HEAD') # the lint job's own steps reach no heavy job + lint_v = lint.replace('-Xmx2g', '-Xmx4g').replace('run: lint', 'run: lint -v') + self.commit({CI: FIXTURE.replace(' plugins:\n', lint_v, 1)}) + self.assertEqual(self.builds(), NONE) + + def test_yaml_1_1_values_python_calls_equal_still_build(self): + # safe_load reads 1 and true (and yes), 0 and false, 0755 and 493 as equal Python values; the runner does not. + for old, new in (('1', 'true'), ('yes', 'true'), ('0', 'false'), ('0755', '493')): + with self.subTest(old=old, new=new): + with_env = lambda v: FIXTURE.replace('ciJars\n', f'ciJars\n env: {{ MODE: {v} }}\n', 1) + self.base = self.commit({CI: with_env(old)}) + self.commit({CI: with_env(new)}) + self.assertEqual(self.builds(), {'build': 'false', 'plugins': 'true'}) + + def test_a_quote_that_changes_the_type_builds(self): + # Same text, another type for the runner: unquoted 3.10 is the number 3.1 (setup-python's README warns about it), + # 1 an int, true a bool, null nothing. Quoting makes each a string, so the job can see a different value. + for old, new in (('[3.10]', "['3.10']"), ('1', "'1'"), ('true', "'true'"), ('010', '"010"'), ('0x10', "'0x10'"), + ('1.0', "'1.0'"), ('null', "'null'"), ('3.10', '3.1'), ('1', '1.0')): + with self.subTest(old=old, new=new): + with_env = lambda v: FIXTURE.replace('ciJars\n', f'ciJars\n env: {{ PY: {v} }}\n', 1) + self.base = self.commit({CI: with_env(old)}) + self.commit({CI: with_env(new)}) + self.assertEqual(self.builds(), {'build': 'false', 'plugins': 'true'}) + + def test_requoting_a_string_comments_and_key_order_build_nothing(self): + # The same type and the same text: 'x' -> "x" -> x for a string, a comment, a blank line, swapped keys. + doc = lambda v, tail: FIXTURE.replace('ciJars\n', f'ciJars\n env: {v}\n', 1) + tail + self.base = self.commit({CI: doc("{ A: 'x', B: '3.10', C: 'true' }", '')}) + for v, tail in (('{ A: "x", B: "3.10", C: "true" }', ''), ('{ C: "true", A: x, B: "3.10" }', '# a note\n\n'), + ("\n B: '3.10' # py\n\n A: x\n C: 'true'", '')): + with self.subTest(v=v): + self.commit({CI: doc(v, tail)}) + self.assertEqual(self.builds(), NONE) + + def test_a_nan_that_stays_builds_nothing(self): # .nan != .nan in Python, yet the same text and type + with_env = lambda tail: FIXTURE.replace('ciJars\n', 'ciJars\n env: { X: .nan }\n', 1) + tail + self.base = self.commit({CI: with_env('')}) + self.commit({CI: with_env('# a note\n').replace('python3 tests.py', 'python3 tests.py -v')}) + self.assertEqual(self.builds(), NONE) + + def test_a_side_only_the_typed_view_cannot_read_builds(self): + # BaseLoader reads 2026-13-45, !custom and !!int x as text; the typed view cannot build them. The edit is in the + # tooling job, which the heavy jobs do not need, so only the fail-closed rule makes them build. + for bad in ('2026-13-45', '!custom x', '!!int x'): + with self.subTest(bad=bad): + self.edit('python3 tests.py', f'python3 tests.py\n env: {{ D: {bad} }}') + self.assertEqual(self.builds(), BOTH) + self.base = self.git('rev-parse', 'HEAD') + + def test_a_renamed_or_missing_job_builds(self): + self.edit(' plugins:\n', ' plugins-new:\n') + self.assertEqual(self.runs('plugins'), 'true') + self.edit(' route:\n', ' router:\n') # a job they need is gone + self.assertEqual(self.builds(), BOTH) + + def test_a_job_name_the_workflow_never_had_builds_when_it_changes(self): # a typo in the `job` input + self.edit('python3 tests.py', 'python3 tests.py -v') + self.assertEqual(self.runs('plugin'), 'true') + self.assertIn('missing on one side', self.outputs('plugin')['reason']) + + def test_a_new_or_deleted_workflow_builds(self): + self.commit({CI: None}) + self.assertEqual(self.builds(), BOTH) + self.base = self.git('rev-parse', 'HEAD') + self.commit({CI: FIXTURE}) + self.assertEqual(self.builds(), BOTH) + + def test_a_wrong_workflow_path_builds(self): # never "the file did not change": it is in neither commit + self.commit({'src/A.java': 'class A { int x; }'}) + for wf in ('.github/workflows/c.yml', '/etc/passwd', '../ci.yml', ''): + with self.subTest(wf=wf): + self.assertEqual(self.runs('plugins', workflow=wf), 'true') + + def test_a_dot_slash_workflow_path_is_the_same_file(self): + self.edit('python3 tests.py', 'python3 tests.py -v') + self.assertEqual(self.runs('plugins', workflow='./' + CI), 'false') + self.assertEqual(self.runs('plugins', workflow='./' + CI, paths=r'^\.github/'), 'false') + + def test_no_base_or_head_builds(self): # first push, force push, a base that is gone: never guess from HEAD~1 + for base in ('', '0' * 40, 'no-such-ref', '--output=x'): + with self.subTest(base=base): + o = self.outputs('plugins', base) + self.assertEqual((o['changed'], o['workflow']), ('true', 'true')) + self.assertIn('not a commit in this checkout', o['reason']) + self.assertEqual(self.runs('plugins', head='0' * 40), 'true') + + def test_a_shallow_checkout_without_the_base_builds(self): + first = self.base + self.commit({'src/A.java': 'class A { int x; }'}) + clone = os.path.join(self.d, 'shallow') + subprocess.run(['git', 'clone', '-q', '--depth', '1', f'file://{self.d}', clone], check=True, env={**os.environ, **ENV}) + self.d = clone + self.assertEqual(self.runs('plugins', first), 'true') + + def test_with_an_unrelated_ci_change_nothing_builds(self): + self.commit({CI: FIXTURE.replace('python3 tests.py', 'python3 tests.py -v'), 'docs/README.md': 'z'}) + self.assertEqual(self.builds(), NONE) + + def test_the_log_escapes_names_and_the_output_has_one_line(self): + job = 'plugins\n::error::x\nchanged=false' + self.edit('python3 tests.py', 'python3 tests.py -v') + o = self.outputs(job) + self.assertEqual(o['changed'], 'true') + self.assertNotIn('\n::error', self.log) + self.assertTrue(o['reason'].startswith('changed: '), o) + + +class Paths(Repo): + def test_a_path_that_matches_builds(self): + self.commit({'src/A.java': 'class A { int x; }'}) + o = self.outputs('plugins', paths='# the sources\n^src/\n\n^lib/') + self.assertEqual((o['changed'], o['workflow']), ('true', 'false')) + self.assertEqual(o['reason'], 'changed: src/A.java matches ^src/') + + def test_no_path_that_matches_builds_nothing(self): + self.commit({'docs/README.md': 'new text'}) + self.assertEqual(self.runs('plugins', paths='^src/'), 'false') + + def test_the_workflow_file_counts_only_through_its_job_compare(self): + self.edit('python3 tests.py', 'python3 tests.py -v') + self.assertEqual(self.runs('plugins', paths='^\\.github/\n.*'), 'false') + + def test_a_rename_out_of_the_matched_folder_builds(self): # rename detection would list only the new path + self.commit({'src/A.java': None, 'docs/A.java': 'class A {}'}) + self.assertEqual(self.outputs('plugins', paths='^src/')['reason'], 'changed: src/A.java matches ^src/') + + def test_a_bad_expression_builds(self): + self.commit({'docs/README.md': 'new text'}) + o = self.outputs('plugins', paths='^src/(') + self.assertEqual(o['changed'], 'true') + self.assertIn('not a regular expression', o['reason']) + + def test_a_paths_value_that_looks_like_an_option_is_a_value(self): + self.commit({'docs/README.md': 'new text'}) + self.assertEqual(self.runs('plugins', paths='--help'), 'false') + + def test_a_failed_diff_builds(self): # only the diff fails: never "nothing changed" + real = job_changed.git + fake = lambda *a: None if a[0] == 'diff' else real(*a) + cwd = os.getcwd() + os.chdir(self.d) + try: + changed, wf, lines = job_changed.decide(self.base, 'HEAD', CI, 'plugins', ['^src/'], run=fake) + finally: + os.chdir(cwd) + self.assertEqual((changed, wf), (True, True)) + self.assertIn('git diff', lines[0]) + + def test_workflow_alone_for_a_test_scope_caller(self): + # A test-scope step needs "does the workflow change reach this job", apart from the build paths (a caller's + # test scope widened to every test on any edit of the workflow file). + self.commit({CI: FIXTURE.replace('python3 tests.py', 'python3 tests.py -v'), 'src/A.java': 'class A { int x; }'}) + o = self.outputs('plugins', paths='^src/') + self.assertEqual((o['changed'], o['workflow']), ('true', 'false')) + self.edit('ciJars', 'ciJars -x') + self.assertEqual(self.outputs('plugins', paths='^src/')['workflow'], 'true') + + +class Units(unittest.TestCase): + def test_no_pyyaml_counts_the_file_and_warns(self): + saved = sys.modules.get('yaml') + sys.modules['yaml'] = None # import yaml raises ImportError + try: + self.assertIn('PyYAML', job_changed.workflow_reason(FIXTURE, FIXTURE, 'plugins')) + with tempfile.TemporaryDirectory() as d: + out = os.path.join(d, 'out') + ids = {'rev-parse:a:x': 'old', 'rev-parse:b:x': 'new'} + fake = lambda *a: ids.get(':'.join(a[0:1] + a[-1:]), 'c') if a[0] == 'rev-parse' else FIXTURE + log = io.StringIO() + with contextlib.redirect_stdout(log), unittest.mock.patch.dict(os.environ, {'GITHUB_OUTPUT': out}): + job_changed.main(['--base', 'a', '--head', 'b', '--workflow', 'x', '--job', 'plugins'], run=fake) + with open(out) as f: + self.assertIn('changed=true\nworkflow=true\nreason=changed: x counts for job plugins: PyYAML', f.read()) + self.assertIn('::warning title=job-changed::PyYAML', log.getvalue()) + finally: + sys.modules['yaml'] = saved + + def test_an_unforeseen_error_still_writes_changed(self): + def boom(*a): + raise OSError('disk') + with tempfile.TemporaryDirectory() as d: + out = os.path.join(d, 'out') + with contextlib.redirect_stdout(io.StringIO()), unittest.mock.patch.dict(os.environ, {'GITHUB_OUTPUT': out}): + self.assertEqual(job_changed.main(['--base', 'a', '--workflow', 'x', '--job', 'j'], run=boom), 0) + with open(out) as f: + self.assertIn('changed=true\nworkflow=true\nreason=changed: job-changed failed (OSError: disk)', f.read()) + + def test_needs_cycles_and_strings_end(self): + jobs = {'a': {'needs': 'b'}, 'b': {'needs': ['a', 'c']}, 'c': {}} + self.assertEqual(job_changed.needed(jobs, 'a'), {'a', 'b', 'c'}) + with self.assertRaises(KeyError): + job_changed.needed({'a': {'needs': 'gone'}}, 'a') + + def test_an_option_like_base_or_head_builds(self): # never handed to git, even where git would take it + def anything(*a): + return 'x' + for base, head in (('--output=/tmp/x', 'HEAD'), ('HEAD', '-p')): + with self.subTest(base=base, head=head): + changed, _, lines = job_changed.decide(base, head, CI, 'build', [], run=anything) + self.assertTrue(changed) + self.assertIn('is not a commit', lines[0]) + + def test_deep_nesting_counts(self): + deep = 'jobs:\n a:\n env: ' + '[' * 3000 + ']' * 3000 + '\n' + self.assertIsNotNone(job_changed.workflow_reason(deep, deep + '# x\n', 'a')) + + +class RealWorkflows(unittest.TestCase): + """The rule on this repository's own workflows, not only on the fixture.""" + def test_every_job_of_every_fork_workflow_parses_and_a_comment_changes_nothing(self): + folder = os.path.join(ROOT, '.github', 'workflows') + names = sorted(n for n in os.listdir(folder) if n.startswith('fork-') and n.endswith('.yml')) + self.assertTrue(names) + for name in names: + with open(os.path.join(folder, name)) as f: + text = f.read() + for job in job_changed.load(text, yaml)['jobs']: + with self.subTest(workflow=name, job=job): + self.assertIsNone(job_changed.workflow_reason(text, text + '\n# a note\n', job)) + + +class Action(unittest.TestCase): + text = open(ACTION).read() + + def test_runs_only_its_own_script_with_inputs_by_env(self): # never ${{ }} inside run: (script injection) + env, run = step() + self.assertTrue(run.startswith('python3 -I "$GITHUB_ACTION_PATH/job_changed.py" '), run) + self.assertNotIn('${{', run) + self.assertNotIn('uses:', self.text) + for name in env: + self.assertIn(f'="${name}"', run) # --x="$X": a value starting with - is never an option + + def test_outputs_come_from_the_step(self): + outputs = yaml.safe_load(self.text)['outputs'] + self.assertEqual({k: v['value'] for k, v in outputs.items()}, + {k: f'${{{{ steps.changed.outputs.{k} }}}}' for k in ('changed', 'workflow', 'reason')}) + + +if __name__ == '__main__': + unittest.main() diff --git a/fork-ci/actions/job-changed/job_changed.py b/fork-ci/actions/job-changed/job_changed.py new file mode 100644 index 0000000000000..1fdb502fb1df2 --- /dev/null +++ b/fork-ci/actions/job-changed/job_changed.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Can the change BASE..HEAD change what one job of a workflow does? + +A change of the workflow file counts for the job only when it can change what the job does: the job's own block, the +block of every job it needs (directly or not, also through a `<<:` merge key: their outputs and results feed it) or the workflow's on, env, defaults +or permissions differ, compared PARSED (PyYAML: comments, blank lines, key order and quoting that keeps the type +('x' -> "x" -> x) never count; aliases are resolved first). A part counts as changed when either of two views differs: +its text (every scalar as written, so 1 -> true or 0755 -> 493 counts) or its types (3.10 -> '3.10' is the number 3.1 +-> a string, 1 -> '1' an int -> a string), compared strictly (1 != True != 1.0). Neither view alone is GitHub's +reading; together they lose nothing it sees. name, run-name and concurrency never change what a job builds or tests. +With --paths, a changed file (git diff BASE HEAD, no rename detection: a rename is its old and its new path) that one +of these regular expressions finds (re.search) counts too; the workflow file itself is judged only by the rule above. + +Fails closed (changed=true, and the log says why): no BASE or HEAD commit here (first push, force push, a base that is +gone, a shallow checkout), the workflow file is not at HEAD (a wrong path must not mean "never changed"), either side +cannot be parsed in either view (an alias without its anchor, a date like 2026-13-45 or an unknown !tag the typed +view cannot build), no PyYAML on the runner (it installs nothing: python3-yaml is the runner's job), the job or one it +needs is missing on either side (renamed, new, deleted), a top-level key this script does not know changed, a git +error, a bad --paths expression, or any other error. Printed text escapes control characters, so a job or file name +cannot start a workflow command or add an output. + + python3 -I job_changed.py --base SHA [--head HEAD] --workflow .github/workflows/ci.yml --job NAME [--paths 'RERE...'] +Writes changed=true|false (the workflow part or a path), workflow=true|false (the workflow part alone) and +reason= to $GITHUB_OUTPUT; exits 0 unless the arguments are wrong. +""" +import argparse, os, re, subprocess, sys + +SHARED = ('on', 'env', 'defaults', 'permissions') # every job runs under these +IGNORED = ('name', 'run-name', 'concurrency') # change no job's steps, inputs or environment +NO_YAML = 'PyYAML is not installed on this runner (install python3-yaml), the change cannot be read' + + +def git(*args): + """stdout, or None when git fails.""" + r = subprocess.run(['git', *args], capture_output=True) + return r.stdout.decode('utf-8', 'surrogateescape') if r.returncode == 0 else None + + +def shown(text): + """One printable line: control characters, line separators and non-UTF-8 bytes escaped.""" + return ''.join(c if c.isprintable() else ascii(c)[1:-1] for c in text) + + +def load(text, yaml): + """The text view: the workflow as a dict with every scalar as its text (BaseLoader). Typed values alone would make 1 + and true, yes and true, 0 and false, 0755 and 493 equal (Python: 1 == True), and read the key `on` as True. Aliases + are still resolved (the composer does that, before any loader).""" + return workflow(yaml.load(text, Loader=yaml.BaseLoader)) + + +def typed(text, yaml): + """The typed view: SafeLoader's values (3.10 a float, '3.10' a str, 1 an int, null None) with every mapping key kept + as its text, so both views have the same keys (`on` stays 'on', not True). The text view alone drops quoting, which + can change the type the runner sees. A value SafeLoader cannot build (2026-13-45, an unknown !tag) raises.""" + class Typed(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) # << merge keys, as SafeLoader does + return {k.value if isinstance(k, yaml.ScalarNode) else self.construct_object(k, deep=deep): + self.construct_object(v, deep=deep) for k, v in node.value} + return workflow(yaml.load(text, Loader=Typed)) + + +def workflow(doc): + if not isinstance(doc, dict) or not isinstance(doc.get('jobs'), dict): + raise ValueError('not a workflow (no jobs mapping)') + return doc + + +def same(a, b): + """Equal and of the same type all the way down: 1 != True != 1.0, '3.10' != 3.1 (Python's == says 1 == True).""" + if type(a) is not type(b): + return False + if isinstance(a, dict): + return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a) + if isinstance(a, list): + return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b)) + return a == b or (a != a and b != b) # .nan != .nan, yet the same text and type + + +def needed(jobs, job): + """job and every job it needs, directly or not; raises KeyError for a job that is not there.""" + seen, todo = set(), [job] + while todo: + j = todo.pop() + if j in seen: + continue + seen.add(j) + block = jobs[j] + wants = block.get('needs', []) if isinstance(block, dict) else [] + todo += [wants] if isinstance(wants, str) else list(wants) + return seen + + +def workflow_reason(old_text, new_text, job): + """Why a change of the workflow file counts for job, or None when it cannot change what job does. + A text is None when the file is not at that side. Importable: a test-scope or other caller can ask the same.""" + try: + import yaml + except ImportError: + return NO_YAML + if old_text is None or new_text is None: + return 'the file is new or deleted' + try: + views = [(view(old_text, yaml), view(new_text, yaml)) for view in (load, typed)] + except (yaml.YAMLError, ValueError, TypeError, OverflowError, RecursionError) as e: # TypeError: unhashable key + return f'it cannot be parsed ({(str(e).splitlines() or [type(e).__name__])[0]})' + (old, new), _ = views + + def differs(get): + return any(not same(get(o), get(n)) for o, n in views) + for key in sorted(set(old) | set(new) | set(views[1][0]) | set(views[1][1]), key=str): + if key == 'jobs' or key in IGNORED or not differs(lambda d: d.get(key)): + continue + return f'workflow-level {key} changed' if key in SHARED else f'the top-level key {key!r}, unknown here, changed' + try: # both views: the text view keeps a `<<:` merge key as a key, so `needs` that comes through it is only typed + names = set().union(*(needed(o['jobs'], job) | needed(n['jobs'], job) for o, n in views)) + except (KeyError, TypeError) as e: + return f'job {e} is missing on one side (renamed, new or deleted) or not a mapping' + for name in sorted(names, key=str): + if differs(lambda d: d['jobs'].get(name)): + return f'job {name}{"" if name == job else f" (which {job} needs)"} changed' + return None + + +def changed_paths(a, b, run=git): + """Paths that differ between commits a and b, renames as delete + add; None on a git error.""" + # --ignore-submodules=none: a .gitmodules `ignore = all` or a runner's diff.ignoreSubmodules hides no gitlink bump + out = run('diff', '--raw', '--no-renames', '--no-abbrev', '-z', '--ignore-submodules=none', a, b) + if out is None: + return None + parts = out.split('\0') + if len(parts) % 2 != 1 or any(not m.startswith(':') for m in parts[0:-1:2]): + return None # not the format we expect: say "unclear" + return parts[1:-1:2] + + +def decide(base, head, wf, job, patterns, run=git): + """(changed, workflow part changed, log lines); the first line is the reason.""" + def fail(why): + return True, True, [f'changed: {why}'] + for name, ref in (('base', base), ('head', head)): + if not ref or ref.startswith('-') or run('rev-parse', '-q', '--verify', f'{ref}^{{commit}}') is None: + return fail(f'the {name} {ref or "(empty)"!r} is not a commit in this checkout (first push, force push, ' + f'a shallow checkout?): everything counts') + wf = os.path.normpath(wf) if wf else '' + if not wf or wf.startswith(('/', '../')) or wf == '..': + return fail(f'the workflow path {wf!r} is not a path inside the repository') + try: + found = [re.compile(p) for p in patterns] + except re.error as e: + return fail(f'a paths expression is not a regular expression ({e})') + old_id, new_id = (run('rev-parse', '-q', '--verify', f'{ref}:{wf}') for ref in (base, head)) + if new_id is None: + return fail(f'{wf} is not in {head} (a wrong workflow path would hide every change)') + lines, why = [], None + if old_id != new_id: + why = workflow_reason(run('show', f'{base}:{wf}'), run('show', f'{head}:{wf}'), job) + if why == NO_YAML: + lines.append(f'::warning title=job-changed::{NO_YAML}') + wf_line = (f'{wf} counts for job {job}: {why}' if why else + f'{wf} is the same at both commits' if old_id == new_id else + f'{wf} changed, but not job {job}, the jobs it needs or the workflow-level {"/".join(SHARED)}') + hit = None + if found and not why: + paths = changed_paths(base, head, run) + if paths is None: + return fail(f'git diff {base} {head} failed: everything counts') + hit = next(((p, r.pattern) for p in paths if p != wf for r in found if r.search(p)), None) + if why: + lines.insert(0, f'changed: {wf_line}') + elif hit: + lines += [f'changed: {hit[0]} matches {hit[1]}', wf_line] + else: + lines += [f'unchanged: {wf_line}' + ('; no changed file matches the paths' if found else '')] + return bool(why or hit), bool(why), lines + + +def main(argv=None, run=git): + p = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + p.add_argument('--base', default='') + p.add_argument('--head', default='HEAD') + p.add_argument('--workflow', required=True, help='the workflow file, e.g. .github/workflows/ci.yml') + p.add_argument('--job', required=True, help='the job id, e.g. "$GITHUB_JOB"') + p.add_argument('--paths', help='one regular expression per line (# comments and blank lines skipped)') + a = p.parse_args(argv) + try: + patterns = [s for s in (line.strip() for line in (a.paths or '').splitlines()) if s and not s.startswith('#')] + changed, wf_changed, lines = decide(a.base, a.head, a.workflow, a.job, patterns, run) + except Exception as e: # an error nobody foresaw is "changed" too, never a step without outputs + changed, wf_changed, lines = True, True, [f'changed: job-changed failed ({type(e).__name__}: {e})'] + lines = [line if line.startswith('::warning title=job-changed::') else shown(line) for line in lines] + print('\n'.join(lines)) + reason = next(line for line in lines if not line.startswith('::')) + out = os.environ.get('GITHUB_OUTPUT') + if out: + with open(out, 'a') as f: + f.write(f'changed={"true" if changed else "false"}\nworkflow={"true" if wf_changed else "false"}\n' + f'reason={reason}\n') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) From 89ab414c17339646ef60c7589089ff78997ab8ef Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:27:02 +0100 Subject: [PATCH 17/27] fork-ci pr-queue 1/4: lens review rules and a strict config (#18) * fork-ci pr-queue 1/4: lens review rules and a strict config The review half of a merge queue, generalised: which head a pull request's lens comments vouch for (lens.py), and the project facts as one JSON config that refuses unknown keys and an empty required-check list (config.py). lens.py is a behaviour-for-behaviour port; testdata/lens-corpus.jsonl replays 2021 recorded calls of the source project's lens tests against it. Two rules the corpus did not pin get their own tests. The workflows job now also finds and runs tests under fork-ci/ (same change as the docs-scope composite action's pull request). Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 1/4: only write-access comments count as lens reads Security review fixes. On a public repository anyone can comment, so a stranger's comment with the lens names could move the reviewed head to an unread push, or disarm a reviewed pull request by looking like a report. - New required config key lens_author_associations: only comments and reviews GitHub records with one of these author associations take part at all, and hidden (minimized) ones do not. Everyone else's are ignored, neither a read nor a lens that read nothing. - A lens comment edited after posting reads nothing until that lens posts again. - A read must be the full 40-hex sha: a short one could be matched by a commit force-pushed later. full_sha is gone; a full sha need not be a commit of the pull request (the doc now says so; the queue compares content). - head_check is required: without it a push to an armed, up-to-date pull request merges unread. - A config key written twice is an error. - The fork-ci-tests test now runs the script on a tree with an unlisted test under fork-ci, instead of comparing a constant. The corpus replay gives the source's posts OWNER and unedited (its repository is private) and checks the 54 answers that change on purpose for their reason. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 1/4: a hidden lens comment reads nothing; a hidden override counts nothing Hiding old review rounds made a reviewed pull request look unreviewed, and the queue then vouched for whatever head it found. A hidden (minimized) lens comment of a counted author is now a lens that read nothing until that lens posts again, like an edited one. A hidden override no longer vouches for a head. Lens-looking posts whose author association does not count are named in the run log. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 1/4: a stranger's comment is never parsed in full (SEC-2 r3 major) Round 3 read every post's text before its author, to log ignored lens-looking posts, so anyone's comment went through clean_inline, whose link and tag patterns took time growing with the square of the text on unclosed brackets (64 KB of `[` took minutes; a few such comments pushed every run past its timeout). The author is checked first again; a post that does not count is read only in its first 4096 characters, only to decide whether to log it. Both patterns now exclude their own opening bracket, so every text is read in linear time. Test: six 64 KB shapes, from a stranger and from a counted author, each under 5 s (the old code did not finish in 60 s). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 1 + .github/scripts/fork-ci-tests.test.cjs | 31 + fork-ci/actions/pr-queue/README.md | 110 + fork-ci/actions/pr-queue/config.py | 125 + fork-ci/actions/pr-queue/lens.py | 319 +++ fork-ci/actions/pr-queue/lens.test.py | 320 +++ .../pr-queue/testdata/lens-corpus.jsonl | 2021 +++++++++++++++++ 7 files changed, 2927 insertions(+) create mode 100644 fork-ci/actions/pr-queue/README.md create mode 100644 fork-ci/actions/pr-queue/config.py create mode 100644 fork-ci/actions/pr-queue/lens.py create mode 100644 fork-ci/actions/pr-queue/lens.test.py create mode 100644 fork-ci/actions/pr-queue/testdata/lens-corpus.jsonl diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index caefbecbc100a..c13beb6db27fc 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -19,6 +19,7 @@ fork-ci/actions/docs-scope/docs-scope.test.py fork-ci/actions/gradle-cache/gradle_cache.test.py fork-ci/actions/job-changed/job-changed.test.py +fork-ci/actions/pr-queue/lens.test.py # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/.github/scripts/fork-ci-tests.test.cjs b/.github/scripts/fork-ci-tests.test.cjs index 3ce9bf2e938b0..da0290dc92daa 100644 --- a/.github/scripts/fork-ci-tests.test.cjs +++ b/.github/scripts/fork-ci-tests.test.cjs @@ -1,4 +1,5 @@ const assert = require("node:assert/strict"); +const childProcess = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -112,3 +113,33 @@ test("the composite actions' tests under fork-ci are found too, and a missing fo fs.rmSync(root, { recursive: true, force: true }); } }); + +test("an unlisted test under .github/scripts or fork-ci fails the run", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fork-ci-tests-")); + try { + const write = (file, text) => { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + }; + const script = fs.readFileSync(require.resolve("./fork-ci-tests.cjs")); + write(".github/scripts/fork-ci-tests.cjs", script); + write(".github/fork-ci-tests.txt", "# nothing listed\n"); + write(".github/scripts/s.test.sh", ""); + write("fork-ci/actions/x/a.test.py", ""); + const env = { ...process.env }; + delete env.GITHUB_STEP_SUMMARY; + const run = childProcess.spawnSync("node", [".github/scripts/fork-ci-tests.cjs"], { + cwd: root, + env, + encoding: "utf8", + }); + assert.equal(run.status, 1, run.stdout + run.stderr); + for (const file of [".github/scripts/s.test.sh", "fork-ci/actions/x/a.test.py"]) + assert.ok( + run.stdout.includes(`${file} is not in .github/fork-ci-tests.txt`), + `${file} not reported: ${run.stdout}`, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/fork-ci/actions/pr-queue/README.md b/fork-ci/actions/pr-queue/README.md new file mode 100644 index 0000000000000..cc374876c8b82 --- /dev/null +++ b/fork-ci/actions/pr-queue/README.md @@ -0,0 +1,110 @@ +# pr-queue + +A merge queue for a repository whose branch ruleset requires pull requests to be +up to date with their base branch. After each push to the base branch it brings +exactly one waiting pull request up to date, and it turns auto-merge back on +only for a head that holds exactly the change the review read. + +This folder is built in four stacked pull requests: + +1. the review rules and the config (`lens.py`, `config.py`): this part; +2. the queue itself (`pr_queue.py`); +3. armed pull requests that cannot merge (a red required check, or one that + ended without passing or never reported) are told once and passed over; +4. the "red only because the base was red" rule, the reusable workflow + `.github/workflows/fork-pr-queue.yml`, `action.yml` and the usage sections + of this file. + +## Config + +One JSON file in the project's repository. Unknown keys, a key written twice, a +missing required key, an empty `required_checks` or a value of the wrong type is +an error: the queue then exits non-zero and touches no pull request. + +```json +{ + "required_checks": ["build", "test", "review-check"], + "merge_method": "squash", + "delete_branch": true, + "bot_logins": ["coderabbitai", "github-actions", "copilot"], + "lens_author_associations": ["OWNER", "COLLABORATOR"], + "override": { "prefix": "tool ready --head", "author_association": "OWNER" }, + "ready_command": "tool ready {pr}", + "push_command": "tool push", + "lens_format_doc": "docs/review.md, section Lens comments", + "head_check": "review-check" +} +``` + +| Key | Required | Meaning | +| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `required_checks` | yes | Exactly the checks the branch ruleset requires. Not empty. | +| `merge_method` | yes | `merge`, `squash` or `rebase`: how auto-merge is turned back on. | +| `delete_branch` | yes | Whether auto-merge deletes the branch after merging. | +| `bot_logins` | yes | Logins whose comments never count as a lens. Any login ending in `[bot]` is a bot too. May be empty. | +| `lens_author_associations` | yes | Whose comments count as lens comments: GitHub's author association of the comment, from `OWNER`, `MEMBER`, `COLLABORATOR`. Everyone else's comments are ignored (Lens comments). `MEMBER` and `COLLABORATOR` do not mean write access: list only those whose holders may all vouch for a head. | +| `override` | yes | The comment that vouches for a head when the lenses read older commits: ` <40-hex sha>: `, posted with this author association (`OWNER`, `MEMBER` or `COLLABORATOR`, with the same caveat) and never edited. | +| `ready_command` | yes | The command people run to re-arm a pull request after a lens read; `{pr}` is the number. Only quoted in the queue's comments. | +| `push_command` | yes | The command people run to push a fixed branch. Only quoted in comments. | +| `lens_format_doc` | yes | Where the project documents its lens comments. Only quoted in comments. | +| `head_check` | yes | The required check that fails a head no lens read; it must be one of `required_checks`. The queue acts only on pull requests that are behind: without this check, a push to an armed pull request that is already up to date merges unread. | +| `wait_seconds` | no | How long to wait for GitHub to show the updated head (default 60). | +| `poll_seconds` | no | How often to look meanwhile (default 5). | +| `list_limit` | no | How many open pull requests to read (default 50). | + +## Lens comments + +A review is posted as lens comments: one comment per review angle, whose +first line is its header. + +``` +lens: correctness · reviewer: · read at: +``` + +`Lens:`, `**Lens:**`, `## lens:` and `- lens:` work too; the separator may be +`·`, `,`, `—` or nothing, and `` is the full 40-hex commit sha. A shorter +sha reads nothing: a force-push can bring a new commit with the same prefix. The +sha need not still be a commit of the pull request (a rebase drops it); the +queue re-arms only a head whose change is exactly the change read there. Each +lens's newest comment counts. The reviewed head is the one commit every lens's +newest read names, or a newer override comment's sha (config `override`). A +pull request with no lens comments is not reviewed, and the queue then vouches +only for the head it found. + +Only comments and reviews that GitHub records as written by one of +`lens_author_associations` count: anyone can comment on a public repository, +so everyone else's comments are neither reads nor reports. The run log names +each lens-looking comment it ignored this way and its author association, so a +wrong `lens_author_associations` shows there. A lens comment edited after +posting, or hidden (minimized), reads nothing until that lens posts a new +comment: an edit can move `read at` to a head nobody read, and hiding old review +rounds must not make a reviewed pull request look unreviewed (the queue would +then vouch for whatever head it found). A lens a later round drops therefore +needs an override, or its old comment deleted. A hidden override counts +nothing. GitHub shows no hidden state for reviews, so a review counts as shown. + +The grammar is fixed: a project writes its lens reports this way, it does not +configure another way. It fails closed. A comment that looks like a lens report +but is not exactly one valid read counts as a lens that read nothing, so the +queue never takes a reviewed pull request for an unreviewed one. Looking like a +report means any of: `lens:`, `read at`, `safe to merge`, a `## ... lens` +heading near the top, or two `covered:`/`checks:` lines. A comment that is no +report says so on its first line, `not a lens: ...`. That clears it unless one of +its lines is a lens header or a `lens:` field with a sha. + +## Where the rules come from + +`lens.py` is a port of a production queue's review rules, behaviour for +behaviour. `testdata/lens-corpus.jsonl` holds every call that project's lens +tests made to its copy, with that copy's answer; `lens.test.py` replays all of +them against `lens.py`. Logins and the project name in the corpus were +replaced, and the replaced corpus was checked against the source copy before it +was committed. A rule change here must keep the corpus green or say in its pull +request why a recorded answer changes. + +The port differs on purpose in three ways, and the replay checks each recorded +answer that changes for its reason. The source's repository is private, so its +comments carry no author association: the replay gives them `OWNER` and +unedited. A read shorter than 40 hex, and a lens comment edited after posting, +read nothing here (54 recorded answers). The source's `full_sha`, which matched +a short read to the pull request's commits, is gone. diff --git a/fork-ci/actions/pr-queue/config.py b/fork-ci/actions/pr-queue/config.py new file mode 100644 index 0000000000000..c4ef42a6a8add --- /dev/null +++ b/fork-ci/actions/pr-queue/config.py @@ -0,0 +1,125 @@ +"""The merge queue's project facts: one JSON file in the project's repository (README.md, "Config"). + +Strict on purpose: an unknown key (a typo such as `required_check`), a key written twice, a missing required key, an +empty `required_checks` or a value of the wrong type is an error, and the queue then touches no PR. A queue that read a +wrong list of required checks would update, wait for or pass over the wrong PRs, so there are no guessed defaults for +anything a project must decide; only timings have defaults. +""" +import json +from dataclasses import dataclass + +MERGE_METHODS = ('merge', 'squash', 'rebase') +# GitHub's CommentAuthorAssociation values a stranger cannot get: the repository's owner, a member of its organisation, +# an invited collaborator. Not CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, FIRST_TIMER or NONE, which anyone gets by commenting +# or by having a commit merged. MEMBER and COLLABORATOR say nothing about the permission (an organisation member may +# only read the repository): a project lists only the associations whose holders may all vouch for a head. +ASSOCIATIONS = ('OWNER', 'MEMBER', 'COLLABORATOR') + + +class ConfigError(Exception): + pass + + +@dataclass(frozen=True) +class Config: + required_checks: frozenset + merge_method: str + delete_branch: bool + bot_logins: tuple + lens_author_associations: tuple + override_prefix: str + override_association: str + ready_command: str + push_command: str + lens_format_doc: str + head_check: str + wait_seconds: int = 60 + poll_seconds: int = 5 + list_limit: int = 50 + + def ready(self, n): + """The command that re-arms PR n after a lens read, as the project writes it.""" + return self.ready_command.replace('{pr}', str(n)) + + +def _text(v): + return isinstance(v, str) and v.strip() == v and v != '' and '\n' not in v + + +def _associations(v): + return isinstance(v, list) and v != [] and all(a in ASSOCIATIONS for a in v) and len(set(v)) == len(v) + + +def _no_duplicates(pairs): + """json.loads keeps the last of a key written twice (say a merge-conflict fix left two `required_checks`): refuse.""" + keys = [k for k, _ in pairs] + twice = sorted({k for k in keys if keys.count(k) > 1}) + if twice: + raise ConfigError(f'key written twice: {", ".join(twice)}') + return dict(pairs) + + +def _int(lo, hi): + return lambda v: isinstance(v, int) and not isinstance(v, bool) and lo <= v <= hi + + +# key: (required, check, what a valid value is) +KEYS = { + 'required_checks': (True, lambda v: isinstance(v, list) and v != [] and all(map(_text, v)) and len(set(v)) == len(v), + 'a non-empty list of distinct check names (exactly the branch ruleset\'s required checks)'), + 'merge_method': (True, lambda v: v in MERGE_METHODS, f'one of {", ".join(MERGE_METHODS)}'), + 'delete_branch': (True, lambda v: isinstance(v, bool), 'true or false'), + 'bot_logins': (True, lambda v: isinstance(v, list) and all(map(_text, v)), + 'a list of logins whose comments never count as a lens (may be empty; `*[bot]` always counts as a bot)'), + 'lens_author_associations': (True, _associations, f'a non-empty list of distinct author associations from ' + f'{", ".join(ASSOCIATIONS)}: only their comments count as lens comments'), + 'override': (True, lambda v: isinstance(v, dict) and set(v) == {'prefix', 'author_association'} + and _text(v['prefix']) and v['author_association'] in ASSOCIATIONS, + f'{{"prefix": "", "author_association": one of {", ".join(ASSOCIATIONS)}}}'), + 'ready_command': (True, lambda v: _text(v) and '{pr}' in v, 'the re-arm command with {pr} for the PR number'), + 'push_command': (True, _text, 'the command that pushes a fixed branch'), + 'lens_format_doc': (True, _text, 'where the project documents its lens comments'), + 'head_check': (True, _text, 'the required check that fails a head no lens read (without one, a push to an armed ' + 'PR that is already up to date merges unread)'), + 'wait_seconds': (False, _int(5, 600), 'whole seconds, 5-600'), + 'poll_seconds': (False, _int(1, 60), 'whole seconds, 1-60'), + 'list_limit': (False, _int(1, 100), 'how many open PRs the queue reads, 1-100'), +} + + +def parse(text, where='config'): + """A Config from the JSON text, or ConfigError naming every problem at once.""" + try: + raw = json.loads(text, object_pairs_hook=_no_duplicates) + except ConfigError as e: + raise ConfigError(f'{where}: {e}') from None + except ValueError as e: + raise ConfigError(f'{where}: not JSON: {e}') from None + if not isinstance(raw, dict): + raise ConfigError(f'{where}: must be a JSON object') + problems = [f'unknown key {k!r} (known: {", ".join(KEYS)})' for k in raw if k not in KEYS] + for key, (required, ok, what) in KEYS.items(): + if key not in raw: + if required: + problems.append(f'{key} is missing: {what}') + elif not ok(raw[key]): + problems.append(f'{key} is {json.dumps(raw[key])[:80]}: must be {what}') + if not problems and raw['head_check'] not in raw['required_checks']: + problems.append(f'head_check {raw["head_check"]!r} is not one of required_checks') + if not problems and raw.get('poll_seconds', 5) > raw.get('wait_seconds', 60): + problems.append('poll_seconds is longer than wait_seconds') + if problems: + raise ConfigError(f'{where}: ' + '; '.join(problems)) + override = raw.pop('override') + return Config(required_checks=frozenset(raw.pop('required_checks')), bot_logins=tuple(raw.pop('bot_logins')), + lens_author_associations=tuple(raw.pop('lens_author_associations')), + override_prefix=override['prefix'], override_association=override['author_association'], **raw) + + +def load(path): + try: + with open(path, encoding='utf-8') as f: + text = f.read() + except OSError as e: + raise ConfigError(f'{path}: cannot read it: {e}') from None + return parse(text, path) diff --git a/fork-ci/actions/pr-queue/lens.py b/fork-ci/actions/pr-queue/lens.py new file mode 100644 index 0000000000000..abd6fdb24ee03 --- /dev/null +++ b/fork-ci/actions/pr-queue/lens.py @@ -0,0 +1,319 @@ +"""Which head a pull request's review vouched for: the lens-comment rules of the merge queue (pr_queue.py). + +A lens is one review angle, posted as one PR comment whose FIRST line is its header: + + lens: · reviewer: · read at: + +also `Lens:`, `**Lens:**`, `## lens:`, `- lens:`. This grammar is fixed (README.md, "Lens comments"): it is the one +format the queue reads, and a project that writes its lens reports differently must change its reports, not this +file. What differs per project comes from the config (config.py): whose comments count at all (author associations; +which logins are bots), and the override comment (` <40-hex sha>: ...`, posted by an account with one author +association and never edited) that vouches for a head when the lenses read older commits. + +Only what GitHub records decides who wrote a comment: one by an account without a configured lens association (anyone +can comment on a public repository) or by a bot is ignored entirely, neither a read nor a lens that read nothing (the +run log names each such lens-looking comment). Among the rest, fail closed throughout: a comment that LOOKS like a lens +report but is not exactly one valid read, or a lens comment edited after posting or hidden (minimized), counts as a +lens that read nothing, so the queue never takes a reviewed PR for an unreviewed one. A hidden override counts nothing. A read is a full 40-hex sha: a shorter one could be matched by a commit pushed later. +""" +import re + +# One normaliser (norm_line) reads every line alike, plain or in a fence, a quote or an indented block: quote, list and +# heading markers, emphasis, backticks, pipes, links and HTML tags go first, so no Markdown shape hides a header or a +# `read at`. A header line (header_line) is a normalised line starting `lens:` with a hex `read at` in it. A first line +# that is indented 4+ spaces, quoted or a fence is never the header (lens_header), but its header line still counts as +# one (lens_problem: fail closed). +LENS_RE = re.compile(r'lens\s*:\s*(.*)', re.I) +# The name ends at the first field: ` · reviewer:` or ` · read at:` (also `read at `; the separator may also be +# `,` `—` or none). A bare `reviewer` or `read at` stays in the name (`data-integrity reviewer 1` and `... 2` are two +# lenses). +FIELD_RE = re.compile(r'\s*(?:[·,—]\s*)?\b(?:reviewer\s*:|read[\s-]*at\b\s*(?::|[0-9a-f]{7}))', re.I) +READ_AT_RE = re.compile(r'\bread[\s-]*at\b\s*:?\s*([0-9a-f]{7,40})\b', re.I) +FULL_SHA = re.compile(r'[0-9a-f]{40}') +NO_HEADER = 'comment without a first-line lens header' +EDITED = 'edited after posting: post a new comment instead' +HIDDEN = 'hidden: post a new comment instead' +EMBEDDED = 'holds a lens header line' # lens_problem: `not a lens:` cannot clear it +# What a lens report looks like (ONE rule on normalised lines): on a plain line (outside code blocks and quotes) a +# `lens:`, a `read at:` or `read at `, `safe to merge`, or in its first TOP_LINES plain lines a Markdown heading +# naming a lens (LOOKS_HEADING_RE reads the raw line: normalising drops `#`), or two `covered:`/`checks:` field lines; +# on ANY line, fenced, indented or quoted too, a `read at` with a hex sha, a header line or a `lens:` field with any hex +# sha (loose_header). Such a comment without a valid first-line header is a lens that read nothing. So is a comment +# with two or more header lines (one comment per lens). A round, fix or note comment says so on its first line, +# `not a lens: ...`; that clears it only when none of its lines is a header line or a `lens:` field with a sha. +LOOKS_RE = re.compile(r'\blens\s*:|\bread[\s-]*at\b\s*(?::|[0-9a-f]{7})|\bsafe\s+to\s+merge\b', re.I) +NOT_A_LENS_RE = re.compile(r'not\s+a\s+lens\s*:', re.I) +FIELD_LINE_RE = re.compile(r'(?:covered|checks)\s*:', re.I) +LOOKS_HEADING_RE = re.compile(r' {0,3}#{1,6}[ \t].*\blens\b', re.I) +TOP_LINES = 5 +# Any hex sha with a digit (7-40 hex, not part of a longer word): `abc1234`, never `defaced` or `facade1x`. +HEX_RE = re.compile(r'(?`, also a `` placeholder), links `[x](y)` -> x, `*` and + backticks, `|` -> space, `_` at a word edge (`_lens_` -> lens; `pr_queue` stays).""" + # [^<>] and [^\[\]] / [^()]: a match cannot start inside another one, so unclosed brackets cost linear time, not + # time that grows with the square of the text (an unclosed `[` x 64 KB took minutes) + text = re.sub(r']*>', '', text) + text = re.sub(r'!?\[([^\[\]]*)\]\([^()]*\)', r'\1', text) + text = re.sub(r'[*`]', '', text).replace('|', ' ') + return re.sub(r'(?|[-+•](?=\s|$)|\d{1,9}[.)](?=\s|$)|#{1,6}(?=\s|$)|~{3,}|\[[ xX]\](?=\s|$))\s*', '', s) + return s.strip() + + +def opens_plain(line): + """The line is no code block, quote or fence (at most 3 leading spaces): only such a first line is a header.""" + return not re.match(r' {4}|\t|[ ]{0,3}(>|`{3}|~{3})', line) + + +def lens_key(name): + """One key per lens across rounds: the whole name, only case and whitespace normalised, minus a trailing round + marker (`r2`, `re-check`, `lens`) and Markdown emphasis around it. Everything else is the lens's identity: + 'data-integrity (DI-1)' and 'data-integrity (DI-2)' are different lenses; 'data-integrity DI-1 r2' = + 'Data-integrity DI-1'.""" + n, prev = name.lower(), None + while n != prev: + prev = n + n = ' '.join(n.split()).strip('*_`') + n = re.sub(r'\s+(r\d+|lens|re-?check)$', '', n) + return n or 'unnamed' + + +def names_a_lens(name): + """A header's name names a lens: it has a letter or digit, is no `<...>` template placeholder and is more than a + round marker (`r4`, `re-check`). Otherwise two such headers would share one key and the newer read would stand for + both.""" + key = lens_key(name) + return bool(re.search(r'[^\W_]', key)) and not re.search(r'<[^<>]*>', name) \ + and not re.fullmatch(r'unnamed|r\d+|lens|re-?check', key) + + +def lens_header(body): + """(name, sha or None) of a comment's lens header, or None. The header is the first non-blank line, if it opens + plain and its normalised form is a `lens: ...` line whose name names a lens.""" + first = next((line for line in (body or '').splitlines() if line.strip()), '') + if not opens_plain(first): + return None + line = norm_line(first) + m = LENS_RE.match(line) + if not m: + return None + rest = m.group(1) + field = FIELD_RE.search(rest) + name = rest[:field.start()] if field else rest + if not names_a_lens(name): + return None + at = READ_AT_RE.search(line) + return name, (at.group(1).lower() if at else None) + + +def plain_lines(body): + """The comment's lines outside fenced blocks, 4-space/tab indented blocks and quotes.""" + out, fence = [], None + for line in (body or '').splitlines(): + m = re.match(r' {0,3}(`{3,}|~{3,})', line) + if fence: + if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence): + fence = None + continue + if m: + fence = m.group(1) + elif line.strip() and not re.match(r' {4}|\t|[ ]{0,3}>', line): + out.append(line) + return out + + +def header_line(line): + """A normalised line that is a lens header with a hex `read at`. A `` template line is none.""" + return bool(LENS_RE.match(line) and READ_AT_RE.search(line)) + + +def loose_header(line): + """A normalised line with a `lens:` field (at its start or after other text) naming any hex sha after it, however + the read is written (`read at commit X`, `reviewed at: X`, `read_at: X`, a commit URL, `(X)`). Never a valid + read; lens_problem flags it.""" + m = re.search(r'\blens\s*:(.*)', re.sub(r'\bnot\s+a\s+lens\s*:', ' ', line, flags=re.I), re.I) # opt-out: no field + return bool(m and HEX_RE.search(m.group(1))) + + +def no_lens_text(text): + """Text that can never count as a lens read, name a lens or look like a lens report (lens_problem), for anything + the queue quotes into a comment (gh errors): no `read at`, no `lens:`, no `safe to merge`.""" + text = clean_inline(text) + text = re.sub(r'\bread([\s-]*)at\b', r'read\1@', text, flags=re.I) + text = re.sub(r'\bsafe(\s+)to(\s+)merge\b', r'safe\1to\2land', text, flags=re.I) + return re.sub(r'\blens(\s*):', r'lens\1-', text, flags=re.I) + + +def check_state(c): + """'ok', 'running' or the failing result's name, for one statusCheckRollup item: a CheckRun (status/conclusion) or a + commit StatusContext (state). Only an unfinished check is running: TIMED_OUT, CANCELLED, STARTUP_FAILURE, + ACTION_REQUIRED, STALE... are failing, never 'running'.""" + if c.get('__typename') == 'StatusContext' or 'conclusion' not in c: + st = (c.get('state') or '').upper() + return 'running' if st in ('', 'PENDING', 'EXPECTED') else 'ok' if st == 'SUCCESS' else st + concl = (c.get('conclusion') or '').upper() + return 'running' if not concl else 'ok' if concl in OK_CONCLUSIONS else concl + + +def latest_checks(rollup): + """The newest item per check (workflow + name, or status context). The rollup lists every run of a check on the + head: a run cancelled because a newer one replaced it stays in it, and must not outvote that newer run. An item that + has not started yet counts as the newest.""" + newest = {} + for c in rollup or []: + key = (c.get('__typename'), c.get('workflowName') or '', c.get('name') or c.get('context') or '') + when = c.get('startedAt') or '9999' + if key not in newest or when >= newest[key][0]: + newest[key] = (when, c) + return [c for _, c in newest.values()] + + +class Review: + """The rules that read a PR's comments, with the project's bot logins and override comment (config.py).""" + + def __init__(self, bot_logins, override_prefix, override_association, lens_associations, log=print): + self.bots = tuple(b.lower() for b in bot_logins) + self.override_re = re.compile(re.escape(override_prefix) + r' ([0-9a-f]{40}):') + self.override_association = override_association + self.lens_associations = tuple(lens_associations) + self.log, self.logged = log, set() + + def is_bot(self, post): + login = ((post.get('author') or {}).get('login') or '').lower() + return login in self.bots or login.endswith('[bot]') + + def counts(self, post): + """The comment or review takes part in the review at all: GitHub records its author with one of the configured + lens associations. Everyone else's posts are ignored entirely: a stranger's lens-looking comment neither + vouches for a head nor blocks one. Hidden (minimized) posts of these authors still take part, as lenses that + read nothing (lens_entries): hiding old rounds is routine, and must not make a reviewed PR look unreviewed.""" + return post.get('authorAssociation') in self.lens_associations + + @staticmethod + def hidden(post): + """Hidden (minimized). gh gives no isMinimized for reviews, so a review counts as shown.""" + return post.get('isMinimized') is True + + def override_sha(self, post): + """The sha a valid override comment vouches for, else None. Text proves nothing, so only what GitHub itself + records counts: written by an account with the configured author association, never edited since + (includesCreatedEdit false) and not hidden. A missing includesCreatedEdit counts as not valid.""" + if post.get('authorAssociation') != self.override_association or post.get('includesCreatedEdit') is not False \ + or self.hidden(post): + return None + m = self.override_re.match(post.get('body') or '') + return m.group(1) if m else None + + def lens_problem(self, post): + """Why a comment that looks like a lens report is not one valid lens read (None: it is one, or it does not look + like a lens report). An override comment never counts. Every line is read normalised, in code blocks and + quotes too. Two or more header lines, a first-line header that names no lens, or a header line that is not the + first plain line: flagged, and `not a lens:` cannot clear it (the text holds EMBEDDED).""" + body = post.get('body') or '' + if self.override_re.match(body): + return None + lines = body.splitlines() + first = next((line for line in lines if line.strip()), '') + normed, start = [norm_line(line) for line in lines], f'starts with {first.strip()[:60]!r}' + at = next((i for i, line in enumerate(lines) if line.strip()), 0) + # a header line, or (after the first line) a line starting `lens:` with a sha in any form (a second lens's + # loosely written read behind a valid header). Only at the line start: `lens:` in mid-line is often a mention. + headers = sum(header_line(line) or (i > at and bool((m := LENS_RE.match(line)) and HEX_RE.search(m.group(1)))) + for i, line in enumerate(normed)) + if headers > 1: + return f'{EMBEDDED} {headers} times (post one comment per lens), {start}' + if lens_header(body): + return None + if opens_plain(first) and LENS_RE.match(norm_line(first)): + return f'its first-line header names no lens (a placeholder, a round or nothing), {start}' + if headers: + return f'{EMBEDDED} that is not its first plain line (a later line, a code block, quote or table), {start}' + if any(loose_header(line) for line in normed): + return f'{EMBEDDED} (a lens line with a sha not written `read at: `), {start}' + if opens_plain(first) and NOT_A_LENS_RE.match(norm_line(first)): + return None + plain = plain_lines(body) + if sum(bool(FIELD_LINE_RE.match(norm_line(line))) for line in plain) >= 2: # fields of a headerless report + return start + if any(READ_AT_RE.search(line) for line in normed) or any(LOOKS_RE.search(norm_line(line)) for line in plain) \ + or any(LOOKS_HEADING_RE.match(line) for line in plain[:TOP_LINES]): + return start + return None + + def lens_posts(self, pr): + """[(when, index, post)] of the PR's comments and reviews, bots skipped.""" + posts = [(c.get('createdAt') or '', c) for c in pr.get('comments') or []] + posts += [(r.get('submittedAt') or '', r) for r in pr.get('reviews') or []] + return [(when, i, post) for i, (when, post) in enumerate(posts) if not self.is_bot(post)] + + def lens_entries(self, pr): + """{lens key: ((when, index), sha or None, why or None)}: each lens's NEWEST header, from the posts that count + (counts). A comment that looks like a lens report but is not one valid read (lens_problem) is an entry of its + own with no sha: it read nothing until it is re-posted as one comment per lens with a first-line header, or an + override covers it. A lens comment edited after posting (includesCreatedEdit not false: GitHub records that it + changed, not what) or hidden (minimized) read nothing until that lens posts a new comment. A lens-looking post + that does not count is logged (log only, never a comment), so a wrong lens_author_associations shows in the + run.""" + found = {} + for when, i, post in self.lens_posts(pr): + where = post.get('url') or f'by {(post.get("author") or {}).get("login") or "?"} at {when or "?"}' + if not self.counts(post): # the author first: a stranger's text is only ever read in part, for the log + head = {**post, 'body': (post.get('body') or '')[:STRANGER_SCAN]} + if where not in self.logged and (self.lens_problem(head) or lens_header(head['body'])): + self.logged.add(where) + association = post.get('authorAssociation') + self.log(f'ignored a lens-looking post {where}: ' + f'{"author association " + association if association else "no author association"} ' + f'is not one of lens_author_associations ({", ".join(self.lens_associations)})') + continue + problem = self.lens_problem(post) + header = None if problem else lens_header(post.get('body') or '') + if problem: + hidden = ', hidden' if self.hidden(post) else '' + found[f'{NO_HEADER} ({where}, {problem}{hidden})'] = ((when, i), None, None) + continue + if not header: + continue + key = lens_key(header[0]) + if key not in found or (when, i) >= found[key][0]: + why = HIDDEN if self.hidden(post) else EDITED if post.get('includesCreatedEdit') is not False else None + found[key] = ((when, i), None, why) if why else ((when, i), header[1], None) + return found + + def reviewed_head(self, pr): + """(reviewed, sha, why). Not reviewed (no lens comments): (False, None, ...). Reviewed: the one full 40-hex sha + every lens's newest read names. A shorter read names nothing: the queue can only match a prefix against the + commits GitHub lists now, and after a force-push a new commit can be made to share the prefix of the dropped + one that was read. A full sha need not be a commit of the PR now (a rebase or force-push drops the read + commit; GitHub keeps it, and the queue compares content). An override covers each lens whose newest read is + older than it. Lenses that name different commits, a short sha or none: sha None.""" + found = self.lens_entries(pr) + overrides = [((when, i), sha) for when, i, post in self.lens_posts(pr) for sha in [self.override_sha(post)] + if sha] + if not found: + return False, None, 'no lens comments' + newest = max(overrides) if overrides else None + reads = {k: (newest[1] if newest and at < newest[0] else sha if FULL_SHA.fullmatch(sha or '') else None) + for k, (at, sha, _) in found.items()} + shas = set(reads.values()) + if len(shas) == 1 and None not in shas: + return True, shas.pop(), 'every lens read it' + + def gap(k): + _, sha, why = found[k] + return why or (f'{sha[:12]}: not a full 40-hex sha' if sha else 'no sha: no single commit of this PR') + listed = ', '.join(f'{k} {v[:12] if v else "(" + gap(k) + ")"}' for k, v in sorted(reads.items())) + return True, None, f'the lenses did not all read one head: {listed}' diff --git a/fork-ci/actions/pr-queue/lens.test.py b/fork-ci/actions/pr-queue/lens.test.py new file mode 100644 index 0000000000000..2f3e80a1019a0 --- /dev/null +++ b/fork-ci/actions/pr-queue/lens.test.py @@ -0,0 +1,320 @@ +"""Tests of lens.py and config.py (run: python3 fork-ci/actions/pr-queue/lens.test.py). + +testdata/lens-corpus.jsonl holds, one case per line, every call the source project's lens tests made to its copy of +these rules, with that copy's answer (README.md, "Where the rules come from"): 2021 cases, replayed here against lens.py. Logins and the +project name in it were replaced (owner, proj); the replaced corpus was checked against the source copy before it was +committed, with the source's override prefix set to `proj ready --head`. Where the port differs from the source on +purpose, the replay says so (Corpus). +""" +import copy, json, os, re, sys, unittest + +sys.dont_write_bytecode = True # no __pycache__ next to the scripts + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import config # noqa: E402 +import lens # noqa: E402 + +CORPUS = os.path.join(HERE, 'testdata', 'lens-corpus.jsonl') +SOURCE = lens.Review(('coderabbitai', 'github-actions', 'copilot'), 'proj ready --head', 'OWNER', ('OWNER',)) +SHA_A, SHA_B, SHA_C = 'a' * 40, 'b' * 40, 'c' * 40 +VALID = { + 'required_checks': ['build', 'test'], 'merge_method': 'squash', 'delete_branch': True, + 'bot_logins': ['review-bot'], 'lens_author_associations': ['OWNER'], + 'override': {'prefix': 'tool ready --head', 'author_association': 'OWNER'}, + 'ready_command': 'tool ready {pr}', 'push_command': 'tool push', 'lens_format_doc': 'docs/review.md', + 'head_check': 'test', +} +# The port's deliberate changes to recorded reviewed_head answers (README.md, "Where the rules come from"): a read +# shorter than 40 hex, or a lens comment edited after posting, is a lens that read nothing. +SHORT_READ = re.compile(r'read[\s*`_-]*at\b[\s*`:_[]*([0-9a-f]{7,39})(?![0-9a-z])', re.I) +CHANGED = ('not a full 40-hex sha', lens.EDITED) + + +def post(body, login='someone', when='2026-01-01T00:00:00Z', **extra): + """A comment as gh shows it, by default from the owner and never edited; a field set to ... is left out.""" + p = {'body': body, 'author': {'login': login}, 'createdAt': when, 'authorAssociation': 'OWNER', + 'includesCreatedEdit': False, **extra} + return {k: v for k, v in p.items() if v is not ...} + + +def stranger(body, when='2026-01-02T00:00:00Z', association='NONE', **extra): + return post(body, login='drive-by', when=when, authorAssociation=association, **extra) + + +def pr(*comments, head=SHA_B, commits=(SHA_A, SHA_B)): + return {'headRefOid': head, 'comments': list(comments), 'reviews': [], 'commits': [{'oid': c} for c in commits]} + + +def as_owner(pr): + """A recorded PR whose posts carry no GitHub author fields: the source's repository is private, so every one was + written by an account with write access; posts that carry a field keep what was recorded.""" + pr = copy.deepcopy(pr) + for p in (pr.get('comments') or []) + (pr.get('reviews') or []): + if 'authorAssociation' not in p and 'includesCreatedEdit' not in p: + p.update(authorAssociation='OWNER', includesCreatedEdit=False) + return pr + + +class Corpus(unittest.TestCase): + def test_every_recorded_call_gives_the_source_answer(self): + """Every recorded answer, except: full_sha is gone (short reads name nothing), and a reviewed_head case whose + input holds a short read or an edited lens comment may only change to no head, for that reason.""" + with open(CORPUS, encoding='utf-8') as f: + cases = [json.loads(line) for line in f] + self.assertEqual(len(cases), 2021) + fns = {'lens_problem': SOURCE.lens_problem, 'reviewed_head': lambda pr: SOURCE.reviewed_head(as_owner(pr)), + 'override_sha': SOURCE.override_sha, 'lens_header': lens.lens_header, 'no_lens_text': lens.no_lens_text, + 'latest_checks': lens.latest_checks, 'check_state': lens.check_state, 'lens_key': lens.lens_key} + self.assertEqual({c['fn'] for c in cases}, set(fns) | {'full_sha'}) + wrong, changed = [], 0 + for c in (c for c in cases if c['fn'] in fns): + got = json.loads(json.dumps(fns[c['fn']](*c['args']))) + if got == c['out']: + continue + posts = (c['args'][0].get('comments') or []) + (c['args'][0].get('reviews') or []) \ + if c['fn'] == 'reviewed_head' else [] + cause = any(SHORT_READ.search(p.get('body') or '') or p.get('includesCreatedEdit') for p in posts) + if cause and got[:2] == [True, None] and any(w in got[2] for w in CHANGED): + changed += 1 + continue + wrong.append(f'{c["fn"]}{json.dumps(c["args"])[:160]}: got {got!r}, source {c["out"]!r}') + self.assertEqual(wrong, [], f'{len(wrong)} of {len(cases)} differ; first: {wrong[:3]}') + self.assertEqual(changed, 54, 'answers changed on purpose (a short read or an edited lens comment)') + + +class Gaps(unittest.TestCase): + """Rules the corpus does not pin (a mutation of each passed it).""" + def test_a_lens_heading_counts_only_in_the_first_five_plain_lines(self): + top = ['Summary', 'one', 'two', 'three'] + self.assertIsNotNone(SOURCE.lens_problem(post('\n'.join(top + ['## Lens correctness', 'text'])))) + self.assertIsNone(SOURCE.lens_problem(post('\n'.join(top + ['four', '## Lens correctness', 'text'])))) + + def test_a_hex_word_without_a_digit_is_no_sha(self): + self.assertIsNone(SOURCE.lens_problem(post('Notes\n```\nlens: effaced\n```'))) + self.assertIn(lens.EMBEDDED, SOURCE.lens_problem(post('Notes\n```\nlens: effaced1\n```'))) + + +class ProjectRules(unittest.TestCase): + """What the config changes: the override comment and the bot logins.""" + def test_the_configured_override_prefix_counts_and_another_does_not(self): + r = lens.Review((), 'tool ready --head', 'OWNER', ('OWNER',)) + mine = post(f'tool ready --head {SHA_B}: read the merge', authorAssociation='OWNER', includesCreatedEdit=False) + other = dict(mine, body=f'proj ready --head {SHA_B}: read the merge') + self.assertEqual(r.override_sha(mine), SHA_B) + self.assertIsNone(r.override_sha(other)) + + def test_an_override_counts_only_from_the_configured_association_and_unedited(self): + r = lens.Review((), 'tool ready --head', 'MEMBER', ('MEMBER',)) + body = f'tool ready --head {SHA_B}: read it' + self.assertEqual(r.override_sha(post(body, authorAssociation='MEMBER', includesCreatedEdit=False)), SHA_B) + for bad in ({'authorAssociation': 'OWNER', 'includesCreatedEdit': False}, # not the configured one + {'authorAssociation': 'MEMBER', 'includesCreatedEdit': True}, # edited after posting + {'authorAssociation': 'MEMBER', 'includesCreatedEdit': ...}): # GitHub did not say + self.assertIsNone(r.override_sha(post(body, **bad)), bad) + + def test_an_override_covers_older_lens_reads(self): + r = lens.Review((), 'tool ready --head', 'OWNER', ('OWNER',)) + p = pr(post(f'lens: correctness · reviewer: x · read at: {SHA_A}', when='2026-01-01T00:00:00Z'), + post(f'tool ready --head {SHA_B}: read the rest', when='2026-01-02T00:00:00Z', + authorAssociation='OWNER', includesCreatedEdit=False)) + self.assertEqual(r.reviewed_head(p), (True, SHA_B, 'every lens read it')) + + def test_a_configured_bot_and_any_login_ending_in_bot_never_count(self): + r = lens.Review(('Review-Bot',), 'tool ready --head', 'OWNER', ('OWNER',)) + body = f'lens: correctness · reviewer: x · read at: {SHA_A}' + for login in ('review-bot', 'helper[bot]'): + self.assertEqual(r.reviewed_head(pr(post(body, login=login))), (False, None, 'no lens comments'), login) + self.assertEqual(r.reviewed_head(pr(post(body))), (True, SHA_A, 'every lens read it')) + + def test_lenses_that_read_different_heads_name_no_head(self): + p = pr(post(f'lens: correctness · reviewer: x · read at: {SHA_A}'), + post(f'lens: data-integrity · reviewer: y · read at: {SHA_B}')) + reviewed, sha, why = SOURCE.reviewed_head(p) + self.assertEqual((reviewed, sha), (True, None)) + self.assertIn('did not all read one head', why) + + +class WhoCounts(unittest.TestCase): + """Only posts GitHub records as written by a configured lens association, shown and never edited, are reads.""" + R = lens.Review((), 'tool ready --head', 'OWNER', ('OWNER', 'COLLABORATOR'), log=lambda line: None) + OWN = post(f'lens: correctness · reviewer: x · read at: {SHA_A}', when='2026-01-01T00:00:00Z') + + def head(self, *posts, commits=(SHA_A, SHA_B)): + return self.R.reviewed_head(pr(*posts, commits=commits)) + + def test_a_strangers_lens_comment_never_moves_the_read_head(self): # the blocker: a forged read of a later push + forged = f'lens: correctness · reviewer: x · read at: {SHA_B}' + for association in ('NONE', 'CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'MEMBER', ...): + self.assertEqual(self.head(self.OWN, stranger(forged, association=association)), + (True, SHA_A, 'every lens read it'), association) + self.assertEqual(self.head(stranger(forged)), (False, None, 'no lens comments')) # nor makes one reviewed + collaborator = post(forged, when='2026-01-02T00:00:00Z', authorAssociation='COLLABORATOR') + self.assertEqual(self.head(self.OWN, collaborator), (True, SHA_B, 'every lens read it'), + 'a configured association counts') + + def test_a_strangers_look_alike_or_junk_comment_neither_vouches_nor_blocks(self): # the denial of service + for junk in ('lgtm, safe to merge', 'looks fine, read at: deadbee1', f'read at: {SHA_A[:7]}', + f'lens: correctness · reviewer: x · read at: {SHA_A[:7]}', f'lens: other · read at: {SHA_B}'): + self.assertEqual(self.head(self.OWN, stranger(junk)), (True, SHA_A, 'every lens read it'), junk) + self.assertIsNotNone(self.R.lens_problem(post('lgtm, safe to merge')), 'the same text from the owner blocks') + + def test_a_hidden_lens_comment_reads_nothing_until_that_lens_posts_again(self): + # hidden (minimized) is how old review rounds are tidied away, so hiding must never make a reviewed PR look + # unreviewed: an unreviewed PR vouches for whatever head it has (the queue's "no lens comments" path) + hidden = dict(self.OWN, isMinimized=True) + reviewed, sha, why = self.head(hidden) + self.assertEqual((reviewed, sha), (True, None), 'every lens comment hidden must not read as "no lens comments"') + self.assertIn(f'correctness ({lens.HIDDEN})', why) + newer = post(f'lens: correctness · reviewer: x · read at: {SHA_B}', when='2026-01-02T00:00:00Z') + self.assertEqual(self.head(self.OWN, newer), (True, SHA_B, 'every lens read it')) + self.assertEqual(self.head(self.OWN, dict(newer, isMinimized=True))[:2], (True, None), + 'a hidden newest read does not bring the older read back') + self.assertEqual(self.head(hidden, newer), (True, SHA_B, 'every lens read it'), 'a new comment of the lens counts') + for junk in ('lgtm, safe to merge', f'lens: correctness · read at {SHA_B[:7]}'): # hidden look-alikes block too + self.assertEqual(self.head(post(junk, isMinimized=True))[:2], (True, None), junk) + + def test_hidden_posts_that_never_counted_stay_ignored(self): + self.assertEqual(self.head(post('thanks, merging soon', isMinimized=True)), (False, None, 'no lens comments')) + forged = stranger(f'lens: correctness · reviewer: x · read at: {SHA_B}', isMinimized=True) + self.assertEqual(self.head(forged), (False, None, 'no lens comments')) + self.assertEqual(self.head(self.OWN, forged), (True, SHA_A, 'every lens read it')) + + def test_a_hidden_override_does_not_count(self): # README: hidden comments count nothing + over = post(f'tool ready --head {SHA_B}: read the rest', when='2026-01-02T00:00:00Z') + self.assertEqual(self.head(self.OWN, over), (True, SHA_B, 'every lens read it')) + self.assertEqual(self.head(self.OWN, dict(over, isMinimized=True)), (True, SHA_A, 'every lens read it')) + + def test_a_strangers_huge_comment_costs_no_time(self): # SEC-2 r3 major: anyone can post 64 KB of `[` + import time + shapes = ('[' * 65536, ' data change applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["**Approach (design / root cause lens, own view first, then the PR body):** I agree with the approach.\n\n```\nlens: design / root cause · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["**Approach (design lens, own view first, then MIGRATIONS.md):** I agree with the runner.\n\n```\nlens: correctness · reviewer: review sub-agent (T3) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nchecks: traced the rerun\nfinding: major a.py:10 rerun -> applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["**Approach (design lens, own view first, then MIGRATIONS.md):** I agree with the runner.\n\n```\nlens: data-integrity DI-1 · reviewer: review sub-agent (T3) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nchecks: traced the rerun\nfinding: major a.py:10 rerun -> applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["**Not a lens:** fix round\n## Lens findings and fixes\nread at: b0b0b0b"], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: no** (2 majors, both small fixes)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: no** (2 majors, both small fixes)\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: yes** (no findings)"], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: yes** (security lens: no blocker, no major. 3 minors, 6 nits.)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: yes** (security lens: no blocker, no major. 3 minors, 6 nits.)\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."], "fn": "lens_header", "out": null} +{"args": ["**Safe to merge: yes**\n\nlens: correctness · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["> lens: correctness · read at: b0b0b0b\n> safe to merge: yes"], "fn": "lens_header", "out": null} +{"args": ["> lens: correctness · reviewer: x · read at: b0b0b0b\nAgreed with the above."], "fn": "lens_header", "out": null} +{"args": ["> proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"], "fn": "lens_header", "out": null} +{"args": ["A brief for the next round:\n\n```\nlens: correctness · read at: b0b0b0b\n```"], "fn": "lens_header", "out": null} +{"args": ["A brief, the template:\n\n```\nlens: · reviewer: · read at: \n```"], "fn": "lens_header", "out": null} +{"args": ["Brief for the lenses, post this:\n```\nlens: · reviewer: () · read at: \ncovered: \n```"], "fn": "lens_header", "out": null} +{"args": ["Checked it all.\n\nsafe to merge: yes"], "fn": "lens_header", "out": null} +{"args": ["DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-1 · reviewer: lens-542-di-1 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n"], "fn": "lens_header", "out": null} +{"args": ["DI-1 lens (Opus xhigh), read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-2 · reviewer: lens-542-di-2 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n"], "fn": "lens_header", "out": null} +{"args": ["DI-2 lens (Opus xhigh), read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nsafe to merge: yes"], "fn": "lens_header", "out": null} +{"args": ["DI-2 lens (Opus xhigh), read at: abc0abc\nsafe to merge: yes\n\n> lens: correctness · reviewer: x (t) · read at: abc0abc\n\nI agree with that."], "fn": "lens_header", "out": null} +{"args": ["DI-2 lens (Opus xhigh), read at: abc0abc\nsafe to merge: yes\n\n```\nlens: correctness · reviewer: x (t) · read at: abc0abc\n```\n\nI agree with that."], "fn": "lens_header", "out": null} +{"args": ["LGTM, thanks"], "fn": "lens_header", "out": null} +{"args": ["Lens: correctness r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness r2", "b0b0b0b"]} +{"args": ["Look:\n\n lens: correctness · read at: b0b0b0b"], "fn": "lens_header", "out": null} +{"args": ["Merged main into it; the lenses come next."], "fn": "lens_header", "out": null} +{"args": ["Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n lens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n | field | value |\n |---|---|\n | lens | correctness |\n | read at | abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> lens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> | field | value |\n> |---|---|\n> | lens | correctness |\n> | read at | abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["Report:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["Round 2 notes\nthe tests lens read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["Round 3 fixes\nlens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["Safe to merge: no\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."], "fn": "lens_header", "out": null} +{"args": ["Safe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["Safe to merge: no\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."], "fn": "lens_header", "out": null} +{"args": ["Safe to merge: yes"], "fn": "lens_header", "out": null} +{"args": ["Summary first\n**Lens:** correctness"], "fn": "lens_header", "out": null} +{"args": ["Thanks, round 7 checked.\n\nScope: the round diff.\nContext line.\nMore context.\nEven more.\n## Correctness lens (Opus high)\ncovered: proj.py lens_problem\nchecks: 40 shapes\nfinding: major proj.py:1100 x"], "fn": "lens_header", "out": null} +{"args": ["\n\nlens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["```\nlens: concurrency\n```"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: correctness · read at: b0b0b0b\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: 0a60d2aab6a60f0c26846f117d59369047afaeb4\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: 508f89d19d21adc3cd262aff7c427d313d86d428\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: correctness · reviewer: x · read at: d6d662c\n```"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: data-integrity DI-1 · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"], "fn": "lens_header", "out": null} +{"args": ["```\nlens: design / root cause · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"], "fn": "lens_header", "out": null} +{"args": ["le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: · reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["lens: · reviewer: lens-a · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["lens: · reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["lens: · reviewer: lens-b · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["lens: · reviewer: x · read at: b0b0b0b"], "fn": "lens_header", "out": null} +{"args": ["lens: Data-integrity DI-1 · reviewer: a (t1) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["Data-integrity DI-1", "abc0abc"]} +{"args": ["lens: Item and data integrity · reviewer: x"], "fn": "lens_header", "out": ["Item and data integrity", null]} +{"args": ["lens: · reviewer: a · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["lens: a · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"], "fn": "lens_header", "out": ["a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]} +{"args": ["lens: b · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none"], "fn": "lens_header", "out": ["b", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]} +{"args": ["lens: c · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"], "fn": "lens_header", "out": ["c", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]} +{"args": ["lens: c · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none"], "fn": "lens_header", "out": ["c", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]} +{"args": ["lens: correctness (pr_queue.py) · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["correctness (pr_queue.py)", "abc0abc"]} +{"args": ["lens: correctness (proj.py) · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["correctness (proj.py)", "abc0abc"]} +{"args": ["lens: correctness (proj.py) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness (proj.py) r2", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: a (t1) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: a (t1) · read at: abc0abc\nfindings: none\ncovered: X.java"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: a (t1) · read at: b0b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b0b"]} +{"args": ["lens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none\nquoting the old round, read at: abc0abc\nlens design said: fine"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: a · read at: 1234567"], "fn": "lens_header", "out": ["correctness", "1234567"]} +{"args": ["lens: correctness · reviewer: a"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)"], "fn": "lens_header", "out": ["correctness", "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: x (t) · read at: 1234567\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": ["correctness", "1234567"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: 174ef306ca6cad085421ddc32c85f24f3df04d7d\nfindings: none"], "fn": "lens_header", "out": ["correctness", "174ef306ca6cad085421ddc32c85f24f3df04d7d"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: 4336224\nfindings: none"], "fn": "lens_header", "out": ["correctness", "4336224"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: 479578f\nfindings: none"], "fn": "lens_header", "out": ["correctness", "479578f"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: 850e438\nfindings: none"], "fn": "lens_header", "out": ["correctness", "850e438"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: 9e5089f\nfindings: none"], "fn": "lens_header", "out": ["correctness", "9e5089f"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: a1a1a1a1a1a1\nfindings: none"], "fn": "lens_header", "out": ["correctness", "a1a1a1a1a1a1"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"], "fn": "lens_header", "out": ["correctness", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: abc0abc\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: bbd7144\nfindings: none"], "fn": "lens_header", "out": ["correctness", "bbd7144"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none"], "fn": "lens_header", "out": ["correctness", "d4d4d4d"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: e00b0ad\nfindings: none"], "fn": "lens_header", "out": ["correctness", "e00b0ad"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: e3e2107\nfindings: none"], "fn": "lens_header", "out": ["correctness", "e3e2107"]} +{"args": ["lens: correctness · reviewer: x (t) · read at: f511b8f\nfindings: none"], "fn": "lens_header", "out": ["correctness", "f511b8f"]} +{"args": ["lens: correctness · reviewer: x · read at commit b0b0b0b"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness · reviewer: x · read at: 1a2b1edf27e29c496a665ebb1a3c7c815967ee9c\nfindings: none"], "fn": "lens_header", "out": ["correctness", "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c"]} +{"args": ["lens: correctness · reviewer: x · read at: 26103a9\nfindings: none"], "fn": "lens_header", "out": ["correctness", "26103a9"]} +{"args": ["lens: correctness · reviewer: x · read at: 32bd303\nfindings: none"], "fn": "lens_header", "out": ["correctness", "32bd303"]} +{"args": ["lens: correctness · reviewer: x · read at: 4ddc4f7\nfindings: none"], "fn": "lens_header", "out": ["correctness", "4ddc4f7"]} +{"args": ["lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none"], "fn": "lens_header", "out": ["correctness", "a1a1a1a"]} +{"args": ["lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"], "fn": "lens_header", "out": ["correctness", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]} +{"args": ["lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\nRe the r7 report (lens: data-integrity DI-2 · read at abc0abc): fixed."], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none"], "fn": "lens_header", "out": ["correctness", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]} +{"args": ["lens: correctness · reviewer: x · read at: d588959\nfindings: none"], "fn": "lens_header", "out": ["correctness", "d588959"]} +{"args": ["lens: correctness · reviewer: x · read at: dededededededededededededededededededede"], "fn": "lens_header", "out": ["correctness", "dededededededededededededededededededede"]} +{"args": ["lens: correctness · reviewer: x · read at: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nfindings: none"], "fn": "lens_header", "out": ["correctness", "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]} +{"args": ["lens: correctness · reviewer: x · read at: fca2958366443b7664d2c47b4995e562b7d7aeee\nfindings: none"], "fn": "lens_header", "out": ["correctness", "fca2958366443b7664d2c47b4995e562b7d7aeee"]} +{"args": ["lens: correctness · reviewer: x"], "fn": "lens_header", "out": ["correctness", null]} +{"args": ["lens: correctness r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness r2", "b0b0b0b"]} +{"args": ["lens: correctness r2 · reviewer: x · read at: a9cc126\nfindings: none"], "fn": "lens_header", "out": ["correctness r2", "a9cc126"]} +{"args": ["lens: correctness r2 · reviewer: x · read at: b00c8cc0dbfdf05db516667eec0cdab343796b14\nfindings: none"], "fn": "lens_header", "out": ["correctness r2", "b00c8cc0dbfdf05db516667eec0cdab343796b14"]} +{"args": ["lens: correctness r2 · reviewer: x · read at: b2b2b2b\nfindings: none"], "fn": "lens_header", "out": ["correctness r2", "b2b2b2b"]} +{"args": ["lens: correctness r2 · reviewer: x · read at: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3\nfindings: none"], "fn": "lens_header", "out": ["correctness r2", "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"]} +{"args": ["lens: correctness, read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": ["lens: correctness, read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["correctness", "b0b0b0b"]} +{"args": ["lens: data-integrity (DI-1 of 2) · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity (DI-1 of 2)", "abc0abc"]} +{"args": ["lens: data-integrity (DI-1 of 2) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity (DI-1 of 2) r2", "b0b0b0b"]} +{"args": ["lens: data-integrity (DI-1) · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity (DI-1)", "abc0abc"]} +{"args": ["lens: data-integrity (DI-1) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity (DI-1) r2", "b0b0b0b"]} +{"args": ["lens: data-integrity (DI-2 of 2) · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity (DI-2 of 2)", "abc0abc"]} +{"args": ["lens: data-integrity (DI-2) · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity (DI-2)", "abc0abc"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: 479578f\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "479578f"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: 6954cd9\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "6954cd9"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: 850e4380baa37266616c13e019733e2dba5776c7\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "850e4380baa37266616c13e019733e2dba5776c7"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: a1a1a1afffff\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "a1a1a1afffff"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "abc0abc"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: bbd7144382c36a1194a24c6d22234a5956385f0d\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "bbd7144382c36a1194a24c6d22234a5956385f0d"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: d4d4d4d\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "d4d4d4d"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: e00b0adda0ed29bba6af45d1b263405d45db9ddf\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "e00b0adda0ed29bba6af45d1b263405d45db9ddf"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: e3e2107\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "e3e2107"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x (t) · read at: f511b8f3e86e\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "f511b8f3e86e"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "a1a1a1a1a"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"], "fn": "lens_header", "out": ["data-integrity DI-1", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"]} +{"args": ["lens: data-integrity DI-1 · reviewer: x · read at: b2b2b2b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "b2b2b2b"]} +{"args": ["lens: data-integrity DI-1 · reviewer: y (t) · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]} +{"args": ["lens: data-integrity DI-1 r2 · reviewer: a (t1) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1 r2", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"]} +{"args": ["lens: data-integrity DI-1 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-1 r4", "b0b0b0b"]} +{"args": ["lens: data-integrity DI-2 · reviewer: x (t) · read at: 6954cd96eca1cd34fc2c9df1c65284687d7aa8c6\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-2", "6954cd96eca1cd34fc2c9df1c65284687d7aa8c6"]} +{"args": ["lens: data-integrity DI-2 · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-2", "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"]} +{"args": ["lens: data-integrity DI-2 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity DI-2 r4", "b0b0b0b"]} +{"args": ["lens: data-integrity · reviewer: twin 1 · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity", "abc0abc"]} +{"args": ["lens: data-integrity · reviewer: twin 2 · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity", "b0b0b0b"]} +{"args": ["lens: data-integrity — DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity — DI-1", "abc0abc"]} +{"args": ["lens: data-integrity — DI-1 r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity — DI-1 r2", "b0b0b0b"]} +{"args": ["lens: data-integrity — DI-2 · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity — DI-2", "abc0abc"]} +{"args": ["lens: data-integrity reviewer 1 · reviewer: x · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": ["data-integrity reviewer 1", "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"]} +{"args": ["lens: data-integrity reviewer 2 · reviewer: x · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": ["data-integrity reviewer 2", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"]} +{"args": ["lens: data-integrity, DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity, DI-1", "abc0abc"]} +{"args": ["lens: data-integrity, DI-1 r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["data-integrity, DI-1 r2", "b0b0b0b"]} +{"args": ["lens: data-integrity, DI-2 · reviewer: x (t) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["data-integrity, DI-2", "abc0abc"]} +{"args": ["lens: design / root cause · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": ["design / root cause", "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"]} +{"args": ["lens: design · reviewer: a (t1) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["design", "abc0abc"]} +{"args": ["lens: design · reviewer: x · read at: aaaaaaa\nfindings: none"], "fn": "lens_header", "out": ["design", "aaaaaaa"]} +{"args": ["lens: di-1 · reviewer: x · read at: 26103a90a5e13f5abecba90fcd6020e98c4a1998\nfindings: none"], "fn": "lens_header", "out": ["di-1", "26103a90a5e13f5abecba90fcd6020e98c4a1998"]} +{"args": ["lens: di-1 · reviewer: x · read at: 4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d\nfindings: none"], "fn": "lens_header", "out": ["di-1", "4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d"]} +{"args": ["lens: di-1 · reviewer: x · read at: 9a9f350\nfindings: none"], "fn": "lens_header", "out": ["di-1", "9a9f350"]} +{"args": ["lens: di-1 · reviewer: x · read at: a1a1a1a\nfindings: none"], "fn": "lens_header", "out": ["di-1", "a1a1a1a"]} +{"args": ["lens: di-1 r2 · reviewer: x · read at: a9cc1269ff51a60311faa036b4971f5478e03a35\nfindings: none"], "fn": "lens_header", "out": ["di-1 r2", "a9cc1269ff51a60311faa036b4971f5478e03a35"]} +{"args": ["lens: di-1 r2 · reviewer: x · read at: b00c8cc\nfindings: none"], "fn": "lens_header", "out": ["di-1 r2", "b00c8cc"]} +{"args": ["lens: di-1 re-check · reviewer: a (t1) · read at: b0b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["di-1 re-check", "b0b0b0b0b"]} +{"args": ["lens: r4 · reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["lens: r4 · reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["lens: tests · reviewer: a (t1) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": ["tests", "abc0abc"]} +{"args": ["lens: tests · reviewer: x (t) · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": ["tests", "b0b0b0b"]} +{"args": ["lens: x · y"], "fn": "lens_header", "out": ["x · y", null]} +{"args": ["looks fine"], "fn": "lens_header", "out": null} +{"args": ["not a lens: ## Lens design r1\n\nlens: design · reviewer: x · read at: b0b0b0b\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: ## Lens design+tests r1 (Opus high)\n\nlens: design+tests · reviewer: x (Opus high) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nfinding: major a.py:3 x -> y (high)\n"], "fn": "lens_header", "out": null} +{"args": ["not a lens: a brief for the next round:\n\n```\nlens: correctness · read at: b0b0b0b\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: a reply\n\n> lens: correctness · read at: b0b0b0b\n> safe to merge: yes"], "fn": "lens_header", "out": null} +{"args": ["not a lens: a reply\n\n> the tests lens said: safe to merge: yes"], "fn": "lens_header", "out": null} +{"args": ["not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: \n lens: data-integrity DI-1 r3 · reviewer: b · read at: \n"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nReport:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying a report\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"], "fn": "lens_header", "out": null} +{"args": ["not a lens: round 5 fixes (commit b0b0b0b). Each finding, then the fix. Safe to merge after CI."], "fn": "lens_header", "out": null} +{"args": ["not a lens: round 5 fixes. DI-1 said safe to merge: yes, read at abc0abc"], "fn": "lens_header", "out": null} +{"args": ["not a lens: round notes"], "fn": "lens_header", "out": null} +{"args": ["not a lens: the brief for round 8\n\nReport:\n\n lens: · reviewer: () · read at: \n covered: "], "fn": "lens_header", "out": null} +{"args": ["not a lens: the brief for round 8\n\nReport:\n\n> lens: · reviewer: () · read at: \n> covered: "], "fn": "lens_header", "out": null} +{"args": ["not a lens: the brief for round 8\n\nReport:\n\n```\nlens: · reviewer: () · read at: \ncovered: \n```"], "fn": "lens_header", "out": null} +{"args": ["not a lens: the brief\n~~~\nlens: correctness r3 · reviewer: a · read at: \n~~~\n"], "fn": "lens_header", "out": null} +{"args": ["not a lens: x"], "fn": "lens_header", "out": null} +{"args": ["pr_queue turned auto-merge OFF: lens x \n read @ abc0abc "], "fn": "lens_header", "out": null} +{"args": ["pr_queue turned auto-merge OFF: lens- x"], "fn": "lens_header", "out": null} +{"args": ["pr_queue turned auto-merge OFF: lens- x, read @: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["pr_queue turned auto-merge OFF: read @ abc0abc"], "fn": "lens_header", "out": null} +{"args": ["pr_queue turned auto-merge OFF: safe to land"], "fn": "lens_header", "out": null} +{"args": ["pr_queue turned auto-merge OFF: the head is not the change read @ b0b0b0b0b0b0\nNeeds a lens read of any new commits, then `proj ready 7`."], "fn": "lens_header", "out": null} +{"args": ["pr_queue: required check livelike is red on d4d4d4d4d4d4, so this armed PR waits and the queue passes over it. Fix the cause and push, or if a new lens read or override covers this head: `proj ready 7`."], "fn": "lens_header", "out": null} +{"args": ["reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["reviewer: lens-a · read at: abc0abc"], "fn": "lens_header", "out": null} +{"args": ["reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"], "fn": "lens_header", "out": null} +{"args": ["reviewer: lens-b · read at: b0b0b0b"], "fn": "lens_header", "out": null} +{"args": ["proj ready --head 15c063d08f075985867ca73dec3dabb016db6934: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"], "fn": "lens_header", "out": null} +{"args": ["proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it"], "fn": "lens_header", "out": null} +{"args": ["proj ready --head abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0: arming ..."], "fn": "lens_header", "out": null} +{"args": ["proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge ..."], "fn": "lens_header", "out": null} +{"args": ["proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge for this head although these lenses did not read it: correctness (read abc0abc). Why:\n\n> orchestrator read the diff"], "fn": "lens_header", "out": null} +{"args": ["proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge for this head although these lenses did not read it: correctness (read abc0abc). Why:\n\n> read A..B myself\n> lens- x · read @: b0b0b0b"], "fn": "lens_header", "out": null} +{"args": ["proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"], "fn": "lens_header", "out": null} +{"args": ["proj ready --head bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: arming auto-merge. Why:\n\n> read it"], "fn": "lens_header", "out": null} +{"args": ["proj ready: required check livelike on aaaaaaaaaaaa: failure, and the re-run of job 9 failed: HTTP 403 read @: cccccccccccccccccccccccccccccccccccccccc\nAuto-merge stays on and this PR waits. Fix the cause, or if a new lens read or override covers this head: `proj ready 7`."], "fn": "lens_header", "out": null} +{"args": ["x\nsafe to land: yes. lens- x, read @: abc0abc, read @ b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"], "fn": "lens_header", "out": null} +{"args": ["| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |"], "fn": "lens_header", "out": null} +{"args": ["| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n| verdict | major |"], "fn": "lens_header", "out": null} +{"args": ["| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"], "fn": "lens_header", "out": ["correctness", "abc0abc"]} +{"args": [""], "fn": "lens_key", "out": "unnamed"} +{"args": ["**data-integrity DI-1 lens**"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["Data-Integrity DI-1"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["Data-integrity DI-1 r2"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["Data-integrity DI-1"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["Item and data integrity"], "fn": "lens_key", "out": "item and data integrity"} +{"args": ["a"], "fn": "lens_key", "out": "a"} +{"args": ["b"], "fn": "lens_key", "out": "b"} +{"args": ["c"], "fn": "lens_key", "out": "c"} +{"args": ["correctness (pr_queue.py)"], "fn": "lens_key", "out": "correctness (pr_queue.py)"} +{"args": ["correctness (proj.py) r2"], "fn": "lens_key", "out": "correctness (proj.py)"} +{"args": ["correctness (proj.py)"], "fn": "lens_key", "out": "correctness (proj.py)"} +{"args": ["correctness r2"], "fn": "lens_key", "out": "correctness"} +{"args": ["correctness"], "fn": "lens_key", "out": "correctness"} +{"args": ["data-integrity (DI-1 of 2) r2"], "fn": "lens_key", "out": "data-integrity (di-1 of 2)"} +{"args": ["data-integrity (DI-1 of 2)"], "fn": "lens_key", "out": "data-integrity (di-1 of 2)"} +{"args": ["data-integrity (DI-1) r2"], "fn": "lens_key", "out": "data-integrity (di-1)"} +{"args": ["data-integrity (DI-1)"], "fn": "lens_key", "out": "data-integrity (di-1)"} +{"args": ["data-integrity (DI-2 of 2)"], "fn": "lens_key", "out": "data-integrity (di-2 of 2)"} +{"args": ["data-integrity (DI-2)"], "fn": "lens_key", "out": "data-integrity (di-2)"} +{"args": ["data-integrity DI-1 (Opus xhigh)"], "fn": "lens_key", "out": "data-integrity di-1 (opus xhigh)"} +{"args": ["data-integrity DI-1 r2"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["data-integrity DI-1 r3 re-check"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["data-integrity DI-1 r3"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["data-integrity DI-1 r4"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["data-integrity DI-1 re-check"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["data-integrity DI-1"], "fn": "lens_key", "out": "data-integrity di-1"} +{"args": ["data-integrity DI-2 r4"], "fn": "lens_key", "out": "data-integrity di-2"} +{"args": ["data-integrity DI-2"], "fn": "lens_key", "out": "data-integrity di-2"} +{"args": ["data-integrity — DI-1 r2"], "fn": "lens_key", "out": "data-integrity — di-1"} +{"args": ["data-integrity — DI-1"], "fn": "lens_key", "out": "data-integrity — di-1"} +{"args": ["data-integrity — DI-2"], "fn": "lens_key", "out": "data-integrity — di-2"} +{"args": ["data-integrity reviewer 1"], "fn": "lens_key", "out": "data-integrity reviewer 1"} +{"args": ["data-integrity reviewer 2"], "fn": "lens_key", "out": "data-integrity reviewer 2"} +{"args": ["data-integrity"], "fn": "lens_key", "out": "data-integrity"} +{"args": ["data-integrity, DI-1 r2"], "fn": "lens_key", "out": "data-integrity, di-1"} +{"args": ["data-integrity, DI-1"], "fn": "lens_key", "out": "data-integrity, di-1"} +{"args": ["data-integrity, DI-2"], "fn": "lens_key", "out": "data-integrity, di-2"} +{"args": ["design / root cause"], "fn": "lens_key", "out": "design / root cause"} +{"args": ["design"], "fn": "lens_key", "out": "design"} +{"args": ["di-1 r2"], "fn": "lens_key", "out": "di-1"} +{"args": ["di-1 re-check"], "fn": "lens_key", "out": "di-1"} +{"args": ["di-1"], "fn": "lens_key", "out": "di-1"} +{"args": ["r4"], "fn": "lens_key", "out": "r4"} +{"args": ["tests"], "fn": "lens_key", "out": "tests"} +{"args": ["x · y"], "fn": "lens_key", "out": "x · y"} +{"args": [{"body": "## Lens correctness (Opus xhigh)\nsafe to merge: yes"}], "fn": "lens_problem", "out": "starts with '## Lens correctness (Opus xhigh)'"} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: 491f7ed919af9c405b9fb73c6a0fdd368b86c6ee\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)'"} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)'"} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)'"} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)'"} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\nfindings: none"}], "fn": "lens_problem", "out": "starts with '## Lens correctness r1 (Opus xhigh)'"} +{"args": [{"body": "## Lens correctness\nsafe to merge: yes"}], "fn": "lens_problem", "out": "starts with '## Lens correctness'"} +{"args": [{"body": "* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "**Approach (correctness lens, own view first, then the PR body):** I agree with the approach.\n\n```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Approach (correctness lens, own view first, then the PR bo'"} +{"args": [{"body": "**Approach (design / root cause lens, own view first, then the PR body):** I agree with the approach.\n\n```\nlens: design / root cause · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Approach (design / root cause lens, own view first, then t'"} +{"args": [{"body": "**Approach (design lens, own view first, then MIGRATIONS.md):** I agree with the runner.\n\n```\nlens: correctness · reviewer: review sub-agent (T3) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nchecks: traced the rerun\nfinding: major a.py:10 rerun -> applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Approach (design lens, own view first, then MIGRATIONS.md)'"} +{"args": [{"body": "**Approach (design lens, own view first, then MIGRATIONS.md):** I agree with the runner.\n\n```\nlens: data-integrity DI-1 · reviewer: review sub-agent (T3) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nchecks: traced the rerun\nfinding: major a.py:10 rerun -> applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Approach (design lens, own view first, then MIGRATIONS.md)'"} +{"args": [{"body": "**Not a lens:** fix round\n## Lens findings and fixes\nread at: b0b0b0b"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "**Safe to merge: no** (2 majors, both small fixes)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: no** (2 majors, both small fixes)'"} +{"args": [{"body": "**Safe to merge: no** (2 majors, both small fixes)\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: no** (2 majors, both small fixes)'"} +{"args": [{"body": "**Safe to merge: yes** (no findings)"}], "fn": "lens_problem", "out": "starts with '**Safe to merge: yes** (no findings)'"} +{"args": [{"body": "**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: yes** (security lens: no blocker)'"} +{"args": [{"body": "**Safe to merge: yes** (security lens: no blocker, no major. 3 minors, 6 nits.)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: yes** (security lens: no blocker, no major.'"} +{"args": [{"body": "**Safe to merge: yes** (security lens: no blocker, no major. 3 minors, 6 nits.)\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: yes** (security lens: no blocker, no major.'"} +{"args": [{"body": "**Safe to merge: yes**\n\nlens: correctness · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: yes**'"} +{"args": [{"body": "+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "> lens: correctness · read at: b0b0b0b\n> safe to merge: yes"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '> lens: correctness · read at: b0b0b0b'"} +{"args": [{"body": "> lens: correctness · reviewer: x · read at: b0b0b0b\nAgreed with the above."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '> lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "> proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "A brief for the next round:\n\n```\nlens: correctness · read at: b0b0b0b\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'A brief for the next round:'"} +{"args": [{"body": "A brief, the template:\n\n```\nlens: · reviewer: · read at: \n```"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "Brief for the lenses, post this:\n```\nlens: · reviewer: () · read at: \ncovered: \n```"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "Briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: b0b0b0b\n lens: data-integrity DI-1 r3 · reviewer: b · read at: b0b0b0b\n"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Briefs for r3, each lens posts:'"} +{"args": [{"body": "Checked it all.\n\nsafe to merge: yes"}], "fn": "lens_problem", "out": "starts with 'Checked it all.'"} +{"args": [{"body": "DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-1 · reviewer: lens-542-di-1 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d98635'"} +{"args": [{"body": "DI-1 lens (Opus xhigh), read at: abc0abc"}], "fn": "lens_problem", "out": "starts with 'DI-1 lens (Opus xhigh), read at: abc0abc'"} +{"args": [{"body": "DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-2 · reviewer: lens-542-di-2 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d98635'"} +{"args": [{"body": "DI-2 lens (Opus xhigh), read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nsafe to merge: yes"}], "fn": "lens_problem", "out": "starts with 'DI-2 lens (Opus xhigh), read at: abc0abc0abc0abc0abc0abc0abc'"} +{"args": [{"body": "DI-2 lens (Opus xhigh), read at: abc0abc\nsafe to merge: yes\n\n> lens: correctness · reviewer: x (t) · read at: abc0abc\n\nI agree with that."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-2 lens (Opus xhigh), read at: abc0abc'"} +{"args": [{"body": "DI-2 lens (Opus xhigh), read at: abc0abc\nsafe to merge: yes\n\n```\nlens: correctness · reviewer: x (t) · read at: abc0abc\n```\n\nI agree with that."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-2 lens (Opus xhigh), read at: abc0abc'"} +{"args": [{"body": "LGTM, thanks"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "Lens: correctness r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "Look:\n\n lens: correctness · read at: b0b0b0b"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Look:'"} +{"args": [{"body": "Merged main into it; the lenses come next."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "Orchestrator: briefs for round 3 (each lens posts its own comment).\n\n```\nBrief for both lenses. The first line of your comment:\n\nlens: data-integrity DI-1 r3 · reviewer: lens-r3-di1 (Opus xhigh) · read at: b0b0b0b\nlens: correctness r3 · reviewer: lens-r3-c (Opus high) · read at: b0b0b0b\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Orchestrator: briefs for round 3 (each lens posts its own co'"} +{"args": [{"body": "Orchestrator: briefs for round 3 (each lens posts its own comment).\n\n```\nBrief for both lenses. The first line of your comment:\n```md\nlens: data-integrity DI-1 r3 · reviewer: lens-r3-di1 (Opus xhigh) · read at: b0b0b0b\nlens: correctness r3 · reviewer: lens-r3-c (Opus high) · read at: b0b0b0b\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Orchestrator: briefs for round 3 (each lens posts its own co'"} +{"args": [{"body": "Orchestrator: briefs for round 3 (each lens posts its own comment).\n\n````text\nBrief for both lenses. The first line of your comment:\n```\nlens: data-integrity DI-1 r3 · reviewer: lens-r3-di1 (Opus xhigh) · read at: b0b0b0b\nlens: correctness r3 · reviewer: lens-r3-c (Opus high) · read at: b0b0b0b\n```\n````"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Orchestrator: briefs for round 3 (each lens posts its own co'"} +{"args": [{"body": "Orchestrator: r2 briefs for the lenses (each posts its own comment):\n\n```\nlens: correctness r2 · reviewer: lens-r2-c (Opus xhigh) · read at: b0b0b0b\nlens: data-integrity DI-1 r2 · reviewer: lens-r2-di1 (Opus xhigh) · read at: b0b0b0b\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Orchestrator: r2 briefs for the lenses (each posts its own c'"} +{"args": [{"body": "Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report: lens: correctness · reviewer: lens-x (Opus xhigh) · '"} +{"args": [{"body": "Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report: lens: correctness · reviewer: lens-x (Opus xhigh) · '"} +{"args": [{"body": "Report:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\n findings: none\n \n lens: tests · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major t.py:3 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n lens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n | field | value |\n |---|---|\n | lens | correctness |\n | read at | abc0abc |"}], "fn": "lens_problem", "out": "starts with 'Report:'"} +{"args": [{"body": "Report:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\n> findings: none\n> \n> lens: tests · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major t.py:3 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> lens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> | field | value |\n> |---|---|\n> | lens | correctness |\n> | read at | abc0abc |"}], "fn": "lens_problem", "out": "starts with 'Report:'"} +{"args": [{"body": "Report:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none\n\nlens: tests · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major t.py:3 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\nlens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n```"}], "fn": "lens_problem", "out": "starts with 'Report:'"} +{"args": [{"body": "Report:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none\n\nlens: tests · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major t.py:3 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Report:\n\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |"}], "fn": "lens_problem", "out": "starts with 'Report:'"} +{"args": [{"body": "Report:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:'"} +{"args": [{"body": "Round 2 notes\nthe tests lens read at abc0abc"}], "fn": "lens_problem", "out": "starts with 'Round 2 notes'"} +{"args": [{"body": "Round 3 fixes\nlens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Round 3 fixes'"} +{"args": [{"body": "Safe to merge: no\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Safe to merge: no'"} +{"args": [{"body": "Safe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Safe to merge: no'"} +{"args": [{"body": "Safe to merge: no\n\nlens: data-integrity DI-1 · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Safe to merge: no'"} +{"args": [{"body": "Safe to merge: yes"}], "fn": "lens_problem", "out": "starts with 'Safe to merge: yes'"} +{"args": [{"body": "Summary first\n**Lens:** correctness"}], "fn": "lens_problem", "out": "starts with 'Summary first'"} +{"args": [{"body": "Thanks, round 7 checked.\n\nScope: the round diff.\nContext line.\nMore context.\nEven more.\n## Correctness lens (Opus high)\ncovered: proj.py lens_problem\nchecks: 40 shapes\nfinding: major proj.py:1100 x"}], "fn": "lens_problem", "out": "starts with 'Thanks, round 7 checked.'"} +{"args": [{"body": "\n\nlens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with '\\ufefflens: correctness · read at: abc0abc'"} +{"args": [{"body": "_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "```\nlens: concurrency\n```"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "```\nlens: correctness · read at: b0b0b0b\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: 0a60d2aab6a60f0c26846f117d59369047afaeb4\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: 508f89d19d21adc3cd262aff7c427d313d86d428\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: correctness · reviewer: x · read at: d6d662c\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: data-integrity DI-1 · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "```\nlens: design / root cause · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```'"} +{"args": [{"body": "le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: · reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: · reviewer: lens-a (Opus xhigh) · read at: abc0'"} +{"args": [{"body": "lens: · reviewer: lens-a · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: · reviewer: lens-a · read at: abc0abc0abc0abc0a'"} +{"args": [{"body": "lens: · reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: · reviewer: lens-b (Opus xhigh) · read at: b0b0'"} +{"args": [{"body": "lens: · reviewer: lens-b · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: · reviewer: lens-b · read at: b0b0b0b0b0b0b0b0b'"} +{"args": [{"body": "lens: · reviewer: x · read at: b0b0b0b"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: Data-integrity DI-1 · reviewer: a (t1) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: Item and data integrity · reviewer: x"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: · reviewer: a · read at: abc0abc"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: · reviewer: a · read at: abc0abc'"} +{"args": [{"body": "lens: a · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: b · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: c · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: c · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness (pr_queue.py) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness (proj.py) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness (proj.py) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a (t1) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a (t1) · read at: abc0abc\nfindings: none\ncovered: X.java"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a (t1) · read at: b0b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none\nlens: design · reviewer: a (t1) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: a (t1) · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: a (t1) · read at: b0b0b0b\nfindings: none\nquoting the old round, read at: abc0abc\nlens design said: fine"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a · read at: 1234567"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: a"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none\n\n---\n\nlens: tests · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major t.py:3 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: lens-x (Opus xhigh) · read at:'"} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none\n\nlens: tests · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major t.py:3 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: lens-x (Opus xhigh) · read at:'"} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 1234567\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 174ef306ca6cad085421ddc32c85f24f3df04d7d\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 4336224\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 479578f\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 850e438\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 9e5089f\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: a1a1a1a1a1a1\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nlens: tests · reviewer: x (t) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x (t) · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: bbd7144\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: e00b0ad\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: e3e2107\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: f511b8f\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at commit b0b0b0b"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 1a2b1edf27e29c496a665ebb1a3c7c815967ee9c\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 26103a9\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 32bd303\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 4ddc4f7\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\nRe the r7 report (lens: data-integrity DI-2 · read at abc0abc): fixed."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n- lens: tests · reviewer: y · read at (abc0abc)\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n- lens: tests · reviewer: y · read at commit abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n- lens: tests · reviewer: y · read_at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n- lens: tests · reviewer: y · reviewed at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n> lens: tests · reviewer: y · read at (abc0abc)\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n> lens: tests · reviewer: y · read at commit abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n> lens: tests · reviewer: y · read_at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n> lens: tests · reviewer: y · reviewed at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n```\nlens: tests · reviewer: y · read at (abc0abc)\n```\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n```\nlens: tests · reviewer: y · read at commit abc0abc\n```\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n```\nlens: tests · reviewer: y · read_at: abc0abc\n```\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\n```\nlens: tests · reviewer: y · reviewed at: abc0abc\n```\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\nlens: tests · reviewer: y · read at (abc0abc)\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\nlens: tests · reviewer: y · read at commit abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\nlens: tests · reviewer: y · read_at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: b0b0b0b\ncovered: a.py\nfindings: none\n\nRelay of the tests report (it could not post):\n\nlens: tests · reviewer: y · reviewed at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'lens: correctness · reviewer: x · read at: b0b0b0b'"} +{"args": [{"body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: d588959\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: dededededededededededededededededededede"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: fca2958366443b7664d2c47b4995e562b7d7aeee\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: a9cc126\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: b00c8cc0dbfdf05db516667eec0cdab343796b14\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: b2b2b2b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness, read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: correctness, read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity (DI-1 of 2) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity (DI-1 of 2) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity (DI-1) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity (DI-1) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity (DI-2 of 2) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity (DI-2) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 479578f\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 6954cd9\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 850e4380baa37266616c13e019733e2dba5776c7\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: a1a1a1afffff\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: bbd7144382c36a1194a24c6d22234a5956385f0d\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: d4d4d4d\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: e00b0adda0ed29bba6af45d1b263405d45db9ddf\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: e3e2107\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: f511b8f3e86e\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: b2b2b2b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: y (t) · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 r2 · reviewer: a (t1) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-2 · reviewer: x (t) · read at: 6954cd96eca1cd34fc2c9df1c65284687d7aa8c6\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-2 · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity DI-2 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity · reviewer: twin 1 · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity · reviewer: twin 2 · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity — DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity — DI-1 r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity — DI-2 · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity reviewer 1 · reviewer: x · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity reviewer 2 · reviewer: x · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity, DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity, DI-1 r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: data-integrity, DI-2 · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: design / root cause · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: design · reviewer: a (t1) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: design · reviewer: x · read at: aaaaaaa\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: 26103a90a5e13f5abecba90fcd6020e98c4a1998\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: 4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: 9a9f350\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: a1a1a1a\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 r2 · reviewer: x · read at: a9cc1269ff51a60311faa036b4971f5478e03a35\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 r2 · reviewer: x · read at: b00c8cc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: di-1 re-check · reviewer: a (t1) · read at: b0b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: r4 · reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: r4 · reviewer: lens-a (Opus xhigh) · read at: abc0abc0'"} +{"args": [{"body": "lens: r4 · reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": "its first-line header names no lens (a placeholder, a round or nothing), starts with 'lens: r4 · reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0'"} +{"args": [{"body": "lens: tests · reviewer: a (t1) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: tests · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "lens: x · y"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "looks fine"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: ## Lens design r1\n\nlens: design · reviewer: x · read at: b0b0b0b\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: ## Lens design r1'"} +{"args": [{"body": "not a lens: ## Lens design+tests r1 (Opus high)\n\nlens: design+tests · reviewer: x (Opus high) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nfinding: major a.py:3 x -> y (high)\n"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: ## Lens design+tests r1 (Opus high)'"} +{"args": [{"body": "not a lens: a brief for the next round:\n\n```\nlens: correctness · read at: b0b0b0b\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: a brief for the next round:'"} +{"args": [{"body": "not a lens: a reply\n\n> lens: correctness · read at: b0b0b0b\n> safe to merge: yes"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: a reply'"} +{"args": [{"body": "not a lens: a reply\n\n> the tests lens said: safe to merge: yes"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: \n lens: data-integrity DI-1 r3 · reviewer: b · read at: \n"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: b0b0b0b\n lens: data-integrity DI-1 r3 · reviewer: b · read at: b0b0b0b\n"}], "fn": "lens_problem", "out": "holds a lens header line 2 times (post one comment per lens), starts with 'not a lens: briefs for r3, each lens posts:'"} +{"args": [{"body": "not a lens: relaying a report\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying a report\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report'"} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at (abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0)\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report'"} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at commit abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report'"} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at: https://github.com/owner/proj/commit/abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report'"} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read at:abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report'"} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report'"} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · reviewed at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"}], "fn": "lens_problem", "out": "holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report'"} +{"args": [{"body": "not a lens: round 5 fixes (commit b0b0b0b). Each finding, then the fix. Safe to merge after CI."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: round 5 fixes. DI-1 said safe to merge: yes, read at abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: round notes"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: the brief for round 8\n\nReport:\n\n lens: · reviewer: () · read at: \n covered: "}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: the brief for round 8\n\nReport:\n\n> lens: · reviewer: () · read at: \n> covered: "}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: the brief for round 8\n\nReport:\n\n```\nlens: · reviewer: () · read at: \ncovered: \n```"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: the brief\n~~~\nlens: correctness r3 · reviewer: a · read at: \n~~~\n"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "not a lens: x"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue turned auto-merge OFF: lens x \n read @ abc0abc "}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue turned auto-merge OFF: lens- x"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue turned auto-merge OFF: lens- x, read @: abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue turned auto-merge OFF: read @ abc0abc"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue turned auto-merge OFF: safe to land"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue turned auto-merge OFF: the head is not the change read @ b0b0b0b0b0b0\nNeeds a lens read of any new commits, then `proj ready 7`."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "pr_queue: required check livelike is red on d4d4d4d4d4d4, so this armed PR waits and the queue passes over it. Fix the cause and push, or if a new lens read or override covers this head: `proj ready 7`."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "lens_problem", "out": "starts with 'reviewer: lens-a (Opus xhigh) · read at: abc0abc0abc0abc0abc'"} +{"args": [{"body": "reviewer: lens-a · read at: abc0abc"}], "fn": "lens_problem", "out": "starts with 'reviewer: lens-a · read at: abc0abc'"} +{"args": [{"body": "reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0\nfindings: none"}], "fn": "lens_problem", "out": "starts with 'reviewer: lens-b (Opus xhigh) · read at: b0b0b0b0b0b0b0b0b0b'"} +{"args": [{"body": "reviewer: lens-b · read at: b0b0b0b"}], "fn": "lens_problem", "out": "starts with 'reviewer: lens-b · read at: b0b0b0b'"} +{"args": [{"body": "proj ready --head 15c063d08f075985867ca73dec3dabb016db6934: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0: arming ..."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge ..."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge for this head although these lenses did not read it: correctness (read abc0abc). Why:\n\n> orchestrator read the diff"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge for this head although these lenses did not read it: correctness (read abc0abc). Why:\n\n> read A..B myself\n> lens- x · read @: b0b0b0b"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready --head bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: arming auto-merge. Why:\n\n> read it"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "proj ready: required check livelike on aaaaaaaaaaaa: failure, and the re-run of job 9 failed: HTTP 403 read @: cccccccccccccccccccccccccccccccccccccccc\nAuto-merge stays on and this PR waits. Fix the cause, or if a new lens read or override covers this head: `proj ready 7`."}], "fn": "lens_problem", "out": null} +{"args": [{"body": "x\nsafe to land: yes. lens- x, read @: abc0abc, read @ b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}], "fn": "lens_problem", "out": null} +{"args": [{"body": "| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |"}], "fn": "lens_problem", "out": "starts with '| field | value |'"} +{"args": [{"body": "| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n| verdict | major |"}], "fn": "lens_problem", "out": "starts with '| field | value |'"} +{"args": [{"body": "| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "lens_problem", "out": null} +{"args": ["read at abc0abc"], "fn": "no_lens_text", "out": "read @ abc0abc"} +{"args": ["GitHub does not have the commit read at dededededede (any more), so it was not updated. Merge main into it yourself and `proj push`."], "fn": "no_lens_text", "out": "GitHub does not have the commit read @ dededededede (any more), so it was not updated. Merge main into it yourself and proj push."} +{"args": ["Safe to merge: yes. lens: x, read at: abc0abc, Read At b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"], "fn": "no_lens_text", "out": "safe to land: yes. lens- x, read @: abc0abc, read @ b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"} +{"args": ["[read at](u) abc0abc"], "fn": "no_lens_text", "out": "read @ abc0abc"} +{"args": ["_lens_: x"], "fn": "no_lens_text", "out": "lens- x"} +{"args": ["a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 0b3be1cf2a94; no lens comments, so it was not re-armed: which head the review vouches for is unclear."], "fn": "no_lens_text", "out": "a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 0b3be1cf2a94; no lens comments, so it was not re-armed: which head the review vouches for is unclear."} +{"args": ["a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 0b3be1cf2a94; the head 0b3be1cf2a94 could not be shown to be the change read at 0b3be1c."], "fn": "no_lens_text", "out": "a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 0b3be1cf2a94; the head 0b3be1cf2a94 could not be shown to be the change read @ 0b3be1c."} +{"args": ["a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 0b3be1cf2a94; the head 18334b910e93 could not be shown to be the change read at 0b3be1cf2a94."], "fn": "no_lens_text", "out": "a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 0b3be1cf2a94; the head 18334b910e93 could not be shown to be the change read @ 0b3be1cf2a94."} +{"args": ["a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 15c063d08f07; the head f9425266a46e could not be shown to be the change read at 15c063d08f07."], "fn": "no_lens_text", "out": "a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 15c063d08f07; the head f9425266a46e could not be shown to be the change read @ 15c063d08f07."} +{"args": ["a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 26103a90a5e1; the head b00c8cc0dbfd could not be shown to be the change read at a9cc1269ff51."], "fn": "no_lens_text", "out": "a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 26103a90a5e1; the head b00c8cc0dbfd could not be shown to be the change read @ a9cc1269ff51."} +{"args": ["a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 26103a90a5e1; the lenses did not all read one head: correctness a9cc1269ff51, di-1 26103a90a5e1, so it was not re-armed: which head the review vouches for is unclear."], "fn": "no_lens_text", "out": "a pr_queue run that was stopped half-way or could not finish turned auto-merge off at 26103a90a5e1; the lenses did not all read one head: correctness a9cc1269ff51, di-1 26103a90a5e1, so it was not re-armed: which head the review vouches for is unclear."} +{"args": ["comment without a first-line lens header (by owner at 2026-11-07T14:20:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d98635'), comment without a first-line lens header (by owner at 2026-11-07T14:21:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d98635')"], "fn": "no_lens_text", "out": "comment without a first-line lens header (by owner at 2026-11-07T14:20:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-1 lens (Opus xhigh), read @: 63509d9863509d9863509d98635'), comment without a first-line lens header (by owner at 2026-11-07T14:21:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-2 lens (Opus xhigh), read @: 63509d9863509d9863509d98635')"} +{"args": ["correctness (read abc0abc)"], "fn": "no_lens_text", "out": "correctness (read abc0abc)"} +{"args": ["failure again after one re-run (job 10)"], "fn": "no_lens_text", "out": "failure again after one re-run (job 10)"} +{"args": ["failure, and job 9 names no workflow run to re-run"], "fn": "no_lens_text", "out": "failure, and job 9 names no workflow run to re-run"} +{"args": ["failure, and the re-run of job 9 failed: HTTP 403 read at: cccccccccccccccccccccccccccccccccccccccc"], "fn": "no_lens_text", "out": "failure, and the re-run of job 9 failed: HTTP 403 read @: cccccccccccccccccccccccccccccccccccccccc"} +{"args": ["gh pr update-branch failed: HTTP 502, read at: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"], "fn": "no_lens_text", "out": "gh pr update-branch failed: HTTP 502, read @: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"} +{"args": ["le**ns**: x, read at: abc0abc"], "fn": "no_lens_text", "out": "lens- x, read @: abc0abc"} +{"args": ["no finished run to judge after 0 h of watching"], "fn": "no_lens_text", "out": "no finished run to judge after 0 h of watching"} +{"args": ["nothing follows this check, the watcher did not start: no user manager"], "fn": "no_lens_text", "out": "nothing follows this check, the watcher did not start: no user manager"} +{"args": ["orchestrator read the diff"], "fn": "no_lens_text", "out": "orchestrator read the diff"} +{"args": ["read A..B myself\nlens: x · read at: b0b0b0b"], "fn": "no_lens_text", "out": "read A..B myself\nlens- x · read @: b0b0b0b"} +{"args": ["safe to merge"], "fn": "no_lens_text", "out": "safe to land"} +{"args": ["the head is not the change read at b0b0b0b0b0b0"], "fn": "no_lens_text", "out": "the head is not the change read @ b0b0b0b0b0b0"} +{"args": ["the lenses did not all read one head: a aaaaaaaaaaaa, b bbbbbbbbbbbb"], "fn": "no_lens_text", "out": "the lenses did not all read one head: a aaaaaaaaaaaa, b bbbbbbbbbbbb"} +{"args": ["the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)') (no sha: no single commit of this PR), so it was not updated: which head the review vouched for is unclear. A comment that looks like a lens report counts only with the skill proj-review section 4 header as its first plain line (not in a code block): re-post it that way, or an orchestrator who read the head runs `proj ready 7 --head --why \"...\"`."], "fn": "no_lens_text", "out": "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)') (no sha: no single commit of this PR), so it was not updated: which head the review vouched for is unclear. A comment that looks like a lens report counts only with the skill proj-review section 4 header as its first plain line (not in a code block): re-post it that way, or an orchestrator who read the head runs proj ready 7 --head --why \"...\"."} +{"args": ["the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR), so it was not updated: which head the review vouched for is unclear. A comment that looks like a lens report counts only with the skill proj-review section 4 header as its first plain line (not in a code block): re-post it that way, or an orchestrator who read the head runs `proj ready 7 --head --why \"...\"`."], "fn": "no_lens_text", "out": "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '') (no sha: no single commit of this PR), so it was not updated: which head the review vouched for is unclear. A comment that looks like a lens report counts only with the skill proj-review section 4 header as its first plain line (not in a code block): re-post it that way, or an orchestrator who read the head runs proj ready 7 --head --why \"...\"."} +{"args": ["the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-09T01:00:00Z, starts with '## Lens correctness') (no sha: no single commit of this PR)"], "fn": "no_lens_text", "out": "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-09T01:00:00Z, starts with '## Lens correctness') (no sha: no single commit of this PR)"} +{"args": ["the lenses did not all read one head: comment without a first-line lens header (by owner at t, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR), so it was not updated: which head the review vouched for is unclear. A comment that looks like a lens report counts only with the skill proj-review section 4 header as its first plain line (not in a code block): re-post it that way, or an orchestrator who read the head runs `proj ready 7 --head --why \"...\"`."], "fn": "no_lens_text", "out": "the lenses did not all read one head: comment without a first-line lens header (by owner at t, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '') (no sha: no single commit of this PR), so it was not updated: which head the review vouched for is unclear. A comment that looks like a lens report counts only with the skill proj-review section 4 header as its first plain line (not in a code block): re-post it that way, or an orchestrator who read the head runs proj ready 7 --head --why \"...\"."} +{"args": ["the lenses did not all read one head: correctness d5889596ac70, di-1 (9a9f350: no single commit of this PR), so it was not updated: which head the review vouched for is unclear."], "fn": "no_lens_text", "out": "the lenses did not all read one head: correctness d5889596ac70, di-1 (9a9f350: no single commit of this PR), so it was not updated: which head the review vouched for is unclear."} +{"args": ["the new head 12f03bfe8853 is not exactly the change read at 5a87817c48c5 merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head 12f03bfe8853 is not exactly the change read @ 5a87817c48c5 merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the new head 5fd5b641efb0 is not exactly the change read at d5889596ac70 merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head 5fd5b641efb0 is not exactly the change read @ d5889596ac70 merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the new head 7190dec5f5c6 is not exactly the change read at e3e2107c7baf merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head 7190dec5f5c6 is not exactly the change read @ e3e2107c7baf merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the new head ce7f21bb89eb is not exactly the change read at 5a87817c48c5 merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head ce7f21bb89eb is not exactly the change read @ 5a87817c48c5 merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the new head d7d0902ecc1c is not exactly the change read at a0ca1aa88bf3 merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head d7d0902ecc1c is not exactly the change read @ a0ca1aa88bf3 merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the new head daecc1f16a35 is not exactly the change read at 9e5089f32d20 merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head daecc1f16a35 is not exactly the change read @ 9e5089f32d20 merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the new head f3c1108692f2 is not exactly the change read at 479578f8de13 merged with main (a push after the lenses, or a conflict resolution?)."], "fn": "no_lens_text", "out": "the new head f3c1108692f2 is not exactly the change read @ 479578f8de13 merged with main (a push after the lenses, or a conflict resolution?)."} +{"args": ["the r1 comments are the old format; r4 read B"], "fn": "no_lens_text", "out": "the r1 comments are the old format; r4 read B"} +{"args": ["turning auto-merge back on for b19ac09cf490 failed: HTTP 502, read at: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"], "fn": "no_lens_text", "out": "turning auto-merge back on for b19ac09cf490 failed: HTTP 502, read @: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"} +{"args": ["x"], "fn": "no_lens_text", "out": "x"} +{"args": ["| lens | x |\n| read at | abc0abc |"], "fn": "no_lens_text", "out": " lens x \n read @ abc0abc "} +{"args": [{"authorAssociation": "COLLABORATOR", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "CONTRIBUTOR", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "MEMBER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "NONE", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "## Lens correctness\nsafe to merge: yes", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: a · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: b · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: c · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: c · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: 1a2b1edf27e29c496a665ebb1a3c7c815967ee9c\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "includesCreatedEdit": true}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "includesCreatedEdit": true}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: fca2958366443b7664d2c47b4995e562b7d7aeee\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "lens: design · reviewer: x · read at: aaaaaaa\nfindings: none", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head 15c063d08f075985867ca73dec3dabb016db6934: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "includesCreatedEdit": false}], "fn": "override_sha", "out": "15c063d08f075985867ca73dec3dabb016db6934"} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": true}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it"}], "fn": "override_sha", "out": null} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0: arming ...", "includesCreatedEdit": false}], "fn": "override_sha", "out": "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge ...", "includesCreatedEdit": false}], "fn": "override_sha", "out": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "includesCreatedEdit": false}], "fn": "override_sha", "out": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"} +{"args": [{"authorAssociation": "OWNER", "body": "proj ready --head bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} +{"args": [{"body": "## Lens correctness (Opus xhigh)\nsafe to merge: yes"}], "fn": "override_sha", "out": null} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: 491f7ed919af9c405b9fb73c6a0fdd368b86c6ee\nchecks: ...\nfinding: major ..."}], "fn": "override_sha", "out": null} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ..."}], "fn": "override_sha", "out": null} +{"args": [{"body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "**Approach (correctness lens, own view first, then the PR body):** I agree with the approach.\n\n```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "**Approach (design lens, own view first, then MIGRATIONS.md):** I agree with the runner.\n\n```\nlens: correctness · reviewer: review sub-agent (T3) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nchecks: traced the rerun\nfinding: major a.py:10 rerun -> applied twice (high)\n```\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "> proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: b0b0b0b\n lens: data-integrity DI-1 r3 · reviewer: b · read at: b0b0b0b\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-1 · reviewer: lens-542-di-1 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-2 · reviewer: lens-542-di-2 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n | field | value |\n |---|---|\n | lens | correctness |\n | read at | abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> | field | value |\n> |---|---|\n> | lens | correctness |\n> | read at | abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Report:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "Safe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: 0a60d2aab6a60f0c26846f117d59369047afaeb4\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: 508f89d19d21adc3cd262aff7c427d313d86d428\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "```\nlens: correctness · reviewer: x · read at: d6d662c\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 174ef306ca6cad085421ddc32c85f24f3df04d7d\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 4336224\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 479578f\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 850e438\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: 9e5089f\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: a1a1a1a1a1a1\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: bbd7144\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: e00b0ad\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: e3e2107\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x (t) · read at: f511b8f\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 26103a9\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 32bd303\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: 4ddc4f7\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: d588959\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness · reviewer: x · read at: dededededededededededededededededededede"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: a9cc126\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: b00c8cc0dbfdf05db516667eec0cdab343796b14\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: b2b2b2b\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: correctness r2 · reviewer: x · read at: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity (DI-1) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity (DI-1) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity (DI-2) · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 479578f\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 6954cd9\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 850e4380baa37266616c13e019733e2dba5776c7\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: a1a1a1afffff\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: bbd7144382c36a1194a24c6d22234a5956385f0d\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: d4d4d4d\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: e00b0adda0ed29bba6af45d1b263405d45db9ddf\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: e3e2107\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: f511b8f3e86e\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 · reviewer: x · read at: b2b2b2b\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-1 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-2 · reviewer: x (t) · read at: 6954cd96eca1cd34fc2c9df1c65284687d7aa8c6\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-2 · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: data-integrity DI-2 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: 26103a90a5e13f5abecba90fcd6020e98c4a1998\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: 4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: 9a9f350\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: di-1 · reviewer: x · read at: a1a1a1a\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: di-1 r2 · reviewer: x · read at: a9cc1269ff51a60311faa036b4971f5478e03a35\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "lens: di-1 r2 · reviewer: x · read at: b00c8cc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: \n lens: data-integrity DI-1 r3 · reviewer: b · read at: \n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: b0b0b0b\n lens: data-integrity DI-1 r3 · reviewer: b · read at: b0b0b0b\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nReport:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying a report\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x"}], "fn": "override_sha", "out": null} +{"args": [{"body": "not a lens: the brief\n~~~\nlens: correctness r3 · reviewer: a · read at: \n~~~\n"}], "fn": "override_sha", "out": null} +{"args": [{"body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "includesCreatedEdit": false}], "fn": "override_sha", "out": null} +{"args": [{"body": "| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n| verdict | major |"}], "fn": "override_sha", "out": null} +{"args": [{"body": "| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |"}], "fn": "override_sha", "out": null} +{"args": [{"comments": [], "commits": [], "headRefOid": "631bd6367177c4ce47dc22326edb6f5ed97d27e2", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "04aa3de2d2b9b5aa35ea284ee0731d17c05607d4"}], "headRefOid": "04aa3de2d2b9b5aa35ea284ee0731d17c05607d4", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "0b3be1cf2a94c0cbb28536919bebb4fbaf0e6c99"}, {"oid": "18334b910e9349d67113f604898e4df644cced1c"}], "headRefOid": "18334b910e9349d67113f604898e4df644cced1c", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "0b3be1cf2a94c0cbb28536919bebb4fbaf0e6c99"}], "headRefOid": "0b3be1cf2a94c0cbb28536919bebb4fbaf0e6c99", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "15c063d08f075985867ca73dec3dabb016db6934"}, {"oid": "f9425266a46ea5aac4415a45a333583810c57597"}], "headRefOid": "f9425266a46ea5aac4415a45a333583810c57597", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "20bdc4e8c41d3a33a497b9c610d977c8acf2eeee"}], "headRefOid": "20bdc4e8c41d3a33a497b9c610d977c8acf2eeee", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "4dae538b4b17219f0a379fd4862d9959f8267308"}, {"oid": "7c20ddb7649128b316da6372bec8390087df348a"}], "headRefOid": "7c20ddb7649128b316da6372bec8390087df348a", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "4dae538b4b17219f0a379fd4862d9959f8267308"}], "headRefOid": "4dae538b4b17219f0a379fd4862d9959f8267308", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d"}], "headRefOid": "4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "577736c2b70df90602f38b735649cb6fde5583df"}], "headRefOid": "577736c2b70df90602f38b735649cb6fde5583df", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "5a87817c48c51f2d1760a5cb1aa620a67a67a5d2"}], "headRefOid": "5a87817c48c51f2d1760a5cb1aa620a67a67a5d2", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "631bd6367177c4ce47dc22326edb6f5ed97d27e2"}], "headRefOid": "631bd6367177c4ce47dc22326edb6f5ed97d27e2", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "738cf7750874272d8c8ce71a7d311dfa76913b3f"}, {"oid": "b19ac09cf4902b00b3128fb3ece5b8158190d033"}], "headRefOid": "b19ac09cf4902b00b3128fb3ece5b8158190d033", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "738cf7750874272d8c8ce71a7d311dfa76913b3f"}], "headRefOid": "738cf7750874272d8c8ce71a7d311dfa76913b3f", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "815cae9d3a82ff4f47a32c21805717cf80d06327"}, {"oid": "d5fbb9e85b88cf466cfc2864f2136e0f7a2e59f8"}], "headRefOid": "d5fbb9e85b88cf466cfc2864f2136e0f7a2e59f8", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "815cae9d3a82ff4f47a32c21805717cf80d06327"}], "headRefOid": "815cae9d3a82ff4f47a32c21805717cf80d06327", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "999e866518f8a9f1cffc2bd66792b7fba40d1814"}, {"oid": "5188bfdcef69fc396e559e259e0c8569ccb03394"}], "headRefOid": "5188bfdcef69fc396e559e259e0c8569ccb03394", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "a0ca1aa88bf3fc0b87a22388448403363e3a4f0c"}], "headRefOid": "a0ca1aa88bf3fc0b87a22388448403363e3a4f0c", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "cf6641204a1e047be19565e42385f04b4e36032e"}], "headRefOid": "cf6641204a1e047be19565e42385f04b4e36032e", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "d2015583d2f74125cfff55613ac1d43137c50c39"}, {"oid": "75440a811e644309583891e3efac4076d049f931"}], "headRefOid": "75440a811e644309583891e3efac4076d049f931", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "d2015583d2f74125cfff55613ac1d43137c50c39"}], "headRefOid": "d2015583d2f74125cfff55613ac1d43137c50c39", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "d5889596ac7010dc5ca54d578f6733eb7d38081f"}], "headRefOid": "d5889596ac7010dc5ca54d578f6733eb7d38081f", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "d6d662cf35e5669a73b2e181c8c62fa601527e11"}], "headRefOid": "d6d662cf35e5669a73b2e181c8c62fa601527e11", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [], "commits": [{"oid": "dffadb4e27cb7341d2b0053364297c8782fb1cb0"}, {"oid": "8632e9e6d121792974ef55296475cb4fb811d20b"}], "headRefOid": "8632e9e6d121792974ef55296475cb4fb811d20b", "reviews": []}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "## Lens correctness\nsafe to merge: yes", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-09T01:00:00Z, starts with '## Lens correctness') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: a · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: b · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: a aaaaaaaaaaaa, b bbbbbbbbbbbb"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: c · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: c · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: 1a2b1edf27e29c496a665ebb1a3c7c815967ee9c\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "1ecec59981f24ba3edc79144fd4f32dd1c7d9f78"}, {"oid": "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c"}], "headRefOid": "1ecec59981f24ba3edc79144fd4f32dd1c7d9f78", "reviews": []}], "fn": "reviewed_head", "out": [true, "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: 1a2b1edf27e29c496a665ebb1a3c7c815967ee9c\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "34fe7e14edfdbb17b12b3d28b27faa16629756cd"}, {"oid": "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c"}], "headRefOid": "34fe7e14edfdbb17b12b3d28b27faa16629756cd", "reviews": []}], "fn": "reviewed_head", "out": [true, "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: 1a2b1edf27e29c496a665ebb1a3c7c815967ee9c\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "ed5643c567a29f8c04ba170474e843ff844be60e"}, {"oid": "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c"}], "headRefOid": "ed5643c567a29f8c04ba170474e843ff844be60e", "reviews": []}], "fn": "reviewed_head", "out": [true, "1a2b1edf27e29c496a665ebb1a3c7c815967ee9c", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: design · reviewer: x · read at: aaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": true}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "coderabbitai"}, "authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "github-actions[bot]"}, "authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "COLLABORATOR", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "CONTRIBUTOR", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "MEMBER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "NONE", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": true}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z"}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "body": "proj ready --head aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: arming auto-merge. Why:\n\n> read it", "createdAt": "2026-11-09T02:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": true}], "commits": [{"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, {"oid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}], "headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "1f074d6d8b63823b2fc4545de5f6db86b08c00b4"}, {"oid": "fca2958366443b7664d2c47b4995e562b7d7aeee"}], "headRefOid": "1f074d6d8b63823b2fc4545de5f6db86b08c00b4", "reviews": []}], "fn": "reviewed_head", "out": [true, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: fca2958366443b7664d2c47b4995e562b7d7aeee\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "1f074d6d8b63823b2fc4545de5f6db86b08c00b4"}, {"oid": "fca2958366443b7664d2c47b4995e562b7d7aeee"}], "headRefOid": "1f074d6d8b63823b2fc4545de5f6db86b08c00b4", "reviews": []}], "fn": "reviewed_head", "out": [true, "fca2958366443b7664d2c47b4995e562b7d7aeee", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: fca2958366443b7664d2c47b4995e562b7d7aeee\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "adfa55d4605b0eac86f80105eb95e0a87e69c59b"}, {"oid": "fca2958366443b7664d2c47b4995e562b7d7aeee"}], "headRefOid": "adfa55d4605b0eac86f80105eb95e0a87e69c59b", "reviews": []}], "fn": "reviewed_head", "out": [true, "fca2958366443b7664d2c47b4995e562b7d7aeee", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "lens: correctness · reviewer: x · read at: fca2958366443b7664d2c47b4995e562b7d7aeee\nfindings: none", "createdAt": "2026-11-09T01:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "e3a70dba4d1c832f743ce8697b2e30416b1fc12b"}, {"oid": "fca2958366443b7664d2c47b4995e562b7d7aeee"}], "headRefOid": "e3a70dba4d1c832f743ce8697b2e30416b1fc12b", "reviews": []}], "fn": "reviewed_head", "out": [true, "fca2958366443b7664d2c47b4995e562b7d7aeee", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "t", "includesCreatedEdit": false}]}], "fn": "reviewed_head", "out": [false, null, "no lens comments"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: 491f7ed919af9c405b9fb73c6a0fdd368b86c6ee\nchecks: ...\nfinding: major ...", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "491f7ed919af9c405b9fb73c6a0fdd368b86c6ee"}, {"oid": "3e3b60c14fb1fb2c77d7c841138196d2402ed024"}], "headRefOid": "3e3b60c14fb1fb2c77d7c841138196d2402ed024", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: Claude Opus 5.5 xhigh (lens sub-agent) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nchecks: ...\nfinding: major ...", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '## Lens correctness r1 (Opus xhigh)') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "**Approach (correctness lens, own view first, then the PR body):** I agree with the approach.\n\n```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Approach (correctness lens, own view first, then the PR bo') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "**Approach (design lens, own view first, then MIGRATIONS.md):** I agree with the runner.\n\n```\nlens: correctness · reviewer: review sub-agent (T3) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py\nchecks: traced the rerun\nfinding: major a.py:10 rerun -> applied twice (high)\n```\n", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Approach (design lens, own view first, then MIGRATIONS.md)') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '**Safe to merge: yes** (security lens: no blocker)') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-1 · reviewer: lens-542-di-1 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n", "createdAt": "2026-11-07T14:20:00Z"}, {"author": {"login": "owner"}, "body": "DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-2 · reviewer: lens-542-di-2 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n", "createdAt": "2026-11-07T14:21:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none", "createdAt": "2026-11-07T17:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-2 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none", "createdAt": "2026-11-07T17:01:00Z"}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0: arming auto-merge ...", "createdAt": "2026-11-07T18:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-1 · reviewer: lens-542-di-1 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n", "createdAt": "2026-11-07T14:20:00Z"}, {"author": {"login": "owner"}, "body": "DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d9863509d9863509d98\n\nlens: data-integrity DI-2 · reviewer: lens-542-di-2 (Opus xhigh) · read at: 63509d9863509d9863509d9863509d9863509d98\n", "createdAt": "2026-11-07T14:21:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none", "createdAt": "2026-11-07T17:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-2 r4 · reviewer: x (t) · read at: b0b0b0b\nfindings: none", "createdAt": "2026-11-07T17:01:00Z"}], "commits": [{"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T14:20:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-1 lens (Opus xhigh), read at: 63509d9863509d9863509d98635') (no sha: no single commit of this PR), comment without a first-line lens header (by owner at 2026-11-07T14:21:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'DI-2 lens (Opus xhigh), read at: 63509d9863509d9863509d98635') (no sha: no single commit of this PR), data-integrity di-1 b0b0b0b0b0b0, data-integrity di-2 b0b0b0b0b0b0"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report: lens: correctness · reviewer: lens-x (Opus xhigh) · ') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report: lens: correctness · reviewer: lens-x (Opus xhigh) · ') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n | field | value |\n |---|---|\n | lens | correctness |\n | read at | abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> | field | value |\n> |---|---|\n> | lens | correctness |\n> | read at | abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Report:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Report:') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "Safe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'Safe to merge: no') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: 0a60d2aab6a60f0c26846f117d59369047afaeb4\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "0a60d2aab6a60f0c26846f117d59369047afaeb4"}, {"oid": "69c698e0b95c60871f2eb654a62df4b9bb953c48"}], "headRefOid": "69c698e0b95c60871f2eb654a62df4b9bb953c48", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "```\nlens: correctness · reviewer: claude-opus-5-5 (read-only) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all)\nchecks:\n- the state question\nfinding: minor (tests) a.py:3 fixed sleep (medium)\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: 508f89d19d21adc3cd262aff7c427d313d86d428\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "508f89d19d21adc3cd262aff7c427d313d86d428"}, {"oid": "19b0450d5c7de63ab48ebbeaa9529489429023e3"}], "headRefOid": "19b0450d5c7de63ab48ebbeaa9529489429023e3", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "```\nlens: correctness · reviewer: review sub-agent (T3, for orchestrator 6287c5f8) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\ncovered: a.py (all), b.py (all)\nchecks: traced the rerun and the half-applied case\nfinding: major a.py:10 empty history -> data change applied twice (high)\n```\n", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "```\nlens: correctness · reviewer: x · read at: d6d662c\n```", "createdAt": "t"}], "commits": [{"oid": "d6d662cf35e5669a73b2e181c8c62fa601527e11"}], "headRefOid": "d6d662cf35e5669a73b2e181c8c62fa601527e11", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at t, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with '```') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 174ef306ca6cad085421ddc32c85f24f3df04d7d\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "174ef306ca6cad085421ddc32c85f24f3df04d7d"}, {"oid": "55432a376184b53dfec9c9d3eb7b60136e728766"}], "headRefOid": "55432a376184b53dfec9c9d3eb7b60136e728766", "reviews": []}], "fn": "reviewed_head", "out": [true, "174ef306ca6cad085421ddc32c85f24f3df04d7d", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 174ef306ca6cad085421ddc32c85f24f3df04d7d\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "174ef306ca6cad085421ddc32c85f24f3df04d7d"}], "headRefOid": "174ef306ca6cad085421ddc32c85f24f3df04d7d", "reviews": []}], "fn": "reviewed_head", "out": [true, "174ef306ca6cad085421ddc32c85f24f3df04d7d", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 4336224\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "43362242a605946a6902f7757e66ca425c003d0a"}], "headRefOid": "43362242a605946a6902f7757e66ca425c003d0a", "reviews": []}], "fn": "reviewed_head", "out": [true, "43362242a605946a6902f7757e66ca425c003d0a", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 850e438\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 850e4380baa37266616c13e019733e2dba5776c7\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "850e4380baa37266616c13e019733e2dba5776c7"}, {"oid": "b17b29c94b502ec73241348b87d012d816c07aa3"}], "headRefOid": "b17b29c94b502ec73241348b87d012d816c07aa3", "reviews": []}], "fn": "reviewed_head", "out": [true, "850e4380baa37266616c13e019733e2dba5776c7", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 850e438\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 850e4380baa37266616c13e019733e2dba5776c7\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "850e4380baa37266616c13e019733e2dba5776c7"}], "headRefOid": "850e4380baa37266616c13e019733e2dba5776c7", "reviews": []}], "fn": "reviewed_head", "out": [true, "850e4380baa37266616c13e019733e2dba5776c7", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 9e5089f\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "9e5089f32d20d4c3d1d47442f74942bfc98e49b5"}], "headRefOid": "9e5089f32d20d4c3d1d47442f74942bfc98e49b5", "reviews": []}], "fn": "reviewed_head", "out": [true, "9e5089f32d20d4c3d1d47442f74942bfc98e49b5", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: a1a1a1a1a1a1\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: a1a1a1afffff\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"oid": "a1a1a1afffffffffffffffffffffffffffffffff"}], "headRefOid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness a1a1a1a1a1a1, data-integrity di-1 a1a1a1afffff"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: bbd7144\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: bbd7144382c36a1194a24c6d22234a5956385f0d\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "bbd7144382c36a1194a24c6d22234a5956385f0d"}, {"oid": "353d33fc69714011d4b8759bc95bcad7b505160b"}], "headRefOid": "353d33fc69714011d4b8759bc95bcad7b505160b", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbd7144382c36a1194a24c6d22234a5956385f0d", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: bbd7144\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: bbd7144382c36a1194a24c6d22234a5956385f0d\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "bbd7144382c36a1194a24c6d22234a5956385f0d"}, {"oid": "77251d7c4ae7f1bb71834a3e8fbb2be4c8a4c121"}], "headRefOid": "77251d7c4ae7f1bb71834a3e8fbb2be4c8a4c121", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbd7144382c36a1194a24c6d22234a5956385f0d", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: bbd7144\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: bbd7144382c36a1194a24c6d22234a5956385f0d\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "bbd7144382c36a1194a24c6d22234a5956385f0d"}], "headRefOid": "bbd7144382c36a1194a24c6d22234a5956385f0d", "reviews": []}], "fn": "reviewed_head", "out": [true, "bbd7144382c36a1194a24c6d22234a5956385f0d", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t2"}, {"author": {"login": "owner"}, "body": "Briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: b0b0b0b\n lens: data-integrity DI-1 r3 · reviewer: b · read at: b0b0b0b\n", "createdAt": "t3"}], "commits": [{"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}, {"oid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "headRefOid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at t3, holds a lens header line 2 times (post one comment per lens), starts with 'Briefs for r3, each lens posts:') (no sha: no single commit of this PR), correctness d4d4d4d4d4d4, data-integrity di-1 d4d4d4d4d4d4"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t2"}, {"author": {"login": "owner"}, "body": "not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: \n lens: data-integrity DI-1 r3 · reviewer: b · read at: \n", "createdAt": "t3"}], "commits": [{"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}, {"oid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "headRefOid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "fn": "reviewed_head", "out": [true, "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t2"}, {"author": {"login": "owner"}, "body": "not a lens: briefs for r3, each lens posts:\n\n lens: correctness r3 · reviewer: a · read at: b0b0b0b\n lens: data-integrity DI-1 r3 · reviewer: b · read at: b0b0b0b\n", "createdAt": "t3"}], "commits": [{"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}, {"oid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "headRefOid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at t3, holds a lens header line 2 times (post one comment per lens), starts with 'not a lens: briefs for r3, each lens posts:') (no sha: no single commit of this PR), correctness d4d4d4d4d4d4, data-integrity di-1 d4d4d4d4d4d4"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: d4d4d4d\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "not a lens: the brief\n~~~\nlens: correctness r3 · reviewer: a · read at: \n~~~\n", "createdAt": "t3"}], "commits": [{"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}, {"oid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "headRefOid": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"}], "fn": "reviewed_head", "out": [true, "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: e00b0ad\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: e00b0adda0ed29bba6af45d1b263405d45db9ddf\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "e00b0adda0ed29bba6af45d1b263405d45db9ddf"}], "headRefOid": "e00b0adda0ed29bba6af45d1b263405d45db9ddf", "reviews": []}], "fn": "reviewed_head", "out": [true, "e00b0adda0ed29bba6af45d1b263405d45db9ddf", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: e3e2107\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: e3e2107\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "e3e2107c7bafe8c548445e0fd4df8f7312b13d9c"}, {"oid": "2b2ac12a390686a83e101378c8d705f37112d7be"}], "headRefOid": "2b2ac12a390686a83e101378c8d705f37112d7be", "reviews": []}], "fn": "reviewed_head", "out": [true, "e3e2107c7bafe8c548445e0fd4df8f7312b13d9c", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: e3e2107\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "e3e2107c7bafe8c548445e0fd4df8f7312b13d9c"}], "headRefOid": "e3e2107c7bafe8c548445e0fd4df8f7312b13d9c", "reviews": []}], "fn": "reviewed_head", "out": [true, "e3e2107c7bafe8c548445e0fd4df8f7312b13d9c", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: f511b8f\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: f511b8f3e86e\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "f511b8f3e86ee7aa0c6ced82c4d1d07ec43d6d28"}], "headRefOid": "f511b8f3e86ee7aa0c6ced82c4d1d07ec43d6d28", "reviews": []}], "fn": "reviewed_head", "out": [true, "f511b8f3e86ee7aa0c6ced82c4d1d07ec43d6d28", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: 26103a9\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: 26103a90a5e13f5abecba90fcd6020e98c4a1998\nfindings: none", "createdAt": "2026-11-07T10:00:01Z"}, {"author": {"login": "owner"}, "body": "lens: correctness r2 · reviewer: x · read at: a9cc126\nfindings: none", "createdAt": "2026-11-07T12:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 r2 · reviewer: x · read at: a9cc1269ff51a60311faa036b4971f5478e03a35\nfindings: none", "createdAt": "2026-11-07T12:00:01Z"}], "commits": [{"oid": "a9cc1269ff51a60311faa036b4971f5478e03a35"}, {"oid": "26103a90a5e13f5abecba90fcd6020e98c4a1998"}, {"oid": "b00c8cc0dbfdf05db516667eec0cdab343796b14"}], "headRefOid": "b00c8cc0dbfdf05db516667eec0cdab343796b14", "reviews": []}], "fn": "reviewed_head", "out": [true, "a9cc1269ff51a60311faa036b4971f5478e03a35", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: 26103a9\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: 26103a90a5e13f5abecba90fcd6020e98c4a1998\nfindings: none", "createdAt": "2026-11-07T10:00:01Z"}, {"author": {"login": "owner"}, "body": "lens: correctness r2 · reviewer: x · read at: a9cc126\nfindings: none", "createdAt": "2026-11-07T12:00:00Z"}], "commits": [{"oid": "a9cc1269ff51a60311faa036b4971f5478e03a35"}, {"oid": "26103a90a5e13f5abecba90fcd6020e98c4a1998"}, {"oid": "b00c8cc0dbfdf05db516667eec0cdab343796b14"}], "headRefOid": "b00c8cc0dbfdf05db516667eec0cdab343796b14", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness a9cc1269ff51, di-1 26103a90a5e1"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: 26103a9\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: 26103a90a5e13f5abecba90fcd6020e98c4a1998\nfindings: none", "createdAt": "2026-11-07T10:00:01Z"}, {"author": {"login": "owner"}, "body": "lens: correctness r2 · reviewer: x · read at: b00c8cc0dbfdf05db516667eec0cdab343796b14\nfindings: none", "createdAt": "2026-11-07T12:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 r2 · reviewer: x · read at: b00c8cc\nfindings: none", "createdAt": "2026-11-07T12:00:01Z"}], "commits": [{"oid": "a9cc1269ff51a60311faa036b4971f5478e03a35"}, {"oid": "26103a90a5e13f5abecba90fcd6020e98c4a1998"}, {"oid": "b00c8cc0dbfdf05db516667eec0cdab343796b14"}], "headRefOid": "b00c8cc0dbfdf05db516667eec0cdab343796b14", "reviews": []}], "fn": "reviewed_head", "out": [true, "b00c8cc0dbfdf05db516667eec0cdab343796b14", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: 32bd303\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head 15c063d08f075985867ca73dec3dabb016db6934: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "2026-11-07T11:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "15c063d08f075985867ca73dec3dabb016db6934"}], "headRefOid": "15c063d08f075985867ca73dec3dabb016db6934", "reviews": []}], "fn": "reviewed_head", "out": [true, "15c063d08f075985867ca73dec3dabb016db6934", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: 4ddc4f7\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: 4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d\nfindings: none", "createdAt": "t2"}], "commits": [{"oid": "4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d"}, {"oid": "f8f5a20526ad2da7006ff0fedd156d53152bca41"}], "headRefOid": "f8f5a20526ad2da7006ff0fedd156d53152bca41", "reviews": []}], "fn": "reviewed_head", "out": [true, "4ddc4f7a98a05c3c7aaf0d78ee870ea82c76430d", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "coderabbitai"}, "authorAssociation": "OWNER", "body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "2026-11-07T11:00:00Z", "includesCreatedEdit": false}], "headRefOid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}], "fn": "reviewed_head", "out": [true, "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "> proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "2026-11-07T11:00:00Z"}], "headRefOid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}], "fn": "reviewed_head", "out": [true, "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:01Z"}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "2026-11-07T11:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "body": "lens: correctness r2 · reviewer: x · read at: b2b2b2b\nfindings: none", "createdAt": "2026-11-07T12:00:00Z"}], "commits": [{"oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"oid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "headRefOid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "fn": "reviewed_head", "out": [true, "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:01Z"}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "2026-11-07T11:00:00Z", "includesCreatedEdit": false}, {"author": {"login": "owner"}, "body": "lens: correctness r2 · reviewer: x · read at: c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3\nfindings: none", "createdAt": "2026-11-07T12:00:00Z"}], "commits": [{"oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"oid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "headRefOid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness c3c3c3c3c3c3, di-1 b2b2b2b2b2b2"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "2026-11-07T10:00:01Z"}, {"author": {"login": "owner"}, "authorAssociation": "OWNER", "body": "proj ready --head b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2: arming auto-merge for this head although these lenses did not read it: x. Why:\n\n> read it myself", "createdAt": "2026-11-07T11:00:00Z", "includesCreatedEdit": false}], "commits": [{"oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"oid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "headRefOid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "fn": "reviewed_head", "out": [true, "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\nfindings: none", "createdAt": "t2"}], "commits": [{"oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"oid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "headRefOid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "fn": "reviewed_head", "out": [true, "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x · read at: a1a1a1a1a\nfindings: none", "createdAt": "t2"}], "headRefOid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}], "fn": "reviewed_head", "out": [true, "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x · read at: b2b2b2b\nfindings: none", "createdAt": "t2"}], "commits": [{"oid": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"oid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "headRefOid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness a1a1a1a1a1a1, data-integrity di-1 b2b2b2b2b2b2"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: a1a1a1a\nfindings: none", "createdAt": "t1"}], "headRefOid": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (a1a1a1a: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: d588959\nfindings: none", "createdAt": "t1"}, {"author": {"login": "owner"}, "body": "lens: di-1 · reviewer: x · read at: 9a9f350\nfindings: none", "createdAt": "t2"}], "commits": [{"oid": "d5889596ac7010dc5ca54d578f6733eb7d38081f"}], "headRefOid": "d5889596ac7010dc5ca54d578f6733eb7d38081f", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness d5889596ac70, di-1 (9a9f350: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x · read at: dededededededededededededededededededede", "createdAt": "t"}], "commits": [{"oid": "631bd6367177c4ce47dc22326edb6f5ed97d27e2"}], "headRefOid": "631bd6367177c4ce47dc22326edb6f5ed97d27e2", "reviews": []}], "fn": "reviewed_head", "out": [true, "dededededededededededededededededededede", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: data-integrity (DI-1) · reviewer: x (t) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity (DI-2) · reviewer: x (t) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity (DI-1) r2 · reviewer: x (t) · read at: b0b0b0b\nfindings: none", "createdAt": "2026-11-07T12:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: data-integrity (di-1) b0b0b0b0b0b0, data-integrity (di-2) (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 479578f\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: correctness · reviewer: x (t) · read at: 479578f\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "479578f8de13f52c47003cd1f6fde3fad20a7ad9"}, {"oid": "a7cada89d12a8d178b1453f97cef1ff9f46a00aa"}], "headRefOid": "a7cada89d12a8d178b1453f97cef1ff9f46a00aa", "reviews": []}], "fn": "reviewed_head", "out": [true, "479578f8de13f52c47003cd1f6fde3fad20a7ad9", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: 6954cd9\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-2 · reviewer: x (t) · read at: 6954cd96eca1cd34fc2c9df1c65284687d7aa8c6\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "commits": [{"oid": "6954cd96eca1cd34fc2c9df1c65284687d7aa8c6"}], "headRefOid": "6954cd96eca1cd34fc2c9df1c65284687d7aa8c6", "reviews": []}], "fn": "reviewed_head", "out": [true, "6954cd96eca1cd34fc2c9df1c65284687d7aa8c6", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "lens: data-integrity DI-1 · reviewer: x (t) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}, {"author": {"login": "owner"}, "body": "lens: data-integrity DI-2 · reviewer: x (t) · read at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0", "reviews": []}], "fn": "reviewed_head", "out": [true, "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0", "every lens read it"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n ## Lens correctness r1 (Opus xhigh)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n **Safe to merge: yes** (security lens: no blocker)\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n - finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n 2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n Safe to merge: no\n \n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n covered: a.py\n findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> ## Lens correctness r1 (Opus xhigh)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> * lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> **Safe to merge: yes** (security lens: no blocker)\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> + lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> - [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> - lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> - finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> 1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> 1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> 2. finding: major a.py:1", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> Report: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> Safe to merge: no\n> \n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> finding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> • lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> _lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> le**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\n> findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n> covered: a.py\n> findings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n> | lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n## Lens correctness r1 (Opus xhigh)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfindings: none\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n* lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n**Safe to merge: yes** (security lens: no blocker)\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n+ lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n- [x] lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n- lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n- finding: major a.py:1 x\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n1) lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n1. lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n2. finding: major a.py:1\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nReport: lens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line (a lens line with a sha not written `read at: `), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n```\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |\n```", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nReport:\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nSafe to merge: no\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\nfinding: major a.py:1 x", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n• lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n_lens_: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nle**ns**: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · **read at:** abc0abc\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at (abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at commit abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: **abc0abc**", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: [abc0abc](https://x/commit/abc0abc)", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: `abc0abc`", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc\ncovered: a.py\nfindings: none", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at: https://github.com/owner/proj/commit/abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read at:abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read-at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · read_at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\nlens: correctness · reviewer: lens-x (Opus xhigh) · reviewed at: abc0abc", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying a report\n\n| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying a report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "not a lens: relaying the DI-2 report\n\nlens: data-integrity DI-2 · reviewer: x · read_at: abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0\nfinding: major a.py:1 x", "createdAt": "2026-11-07T11:00:00Z"}], "commits": [{"oid": "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0"}, {"oid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T11:00:00Z, holds a lens header line that is not its first plain line (a later line, a code block, quote or table), starts with 'not a lens: relaying the DI-2 report') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, starts with '| field | value |') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "| field | value |\n|---|---|\n| lens | correctness |\n| read at | abc0abc |\n| verdict | major |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by owner at 2026-11-07T10:00:00Z, starts with '| field | value |') (no sha: no single commit of this PR)"]} +{"args": [{"comments": [{"author": {"login": "owner"}, "body": "| lens: correctness · reviewer: lens-x (Opus xhigh) · read at: abc0abc |", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: correctness (abc0abc: no single commit of this PR)"]} +{"args": [{"comments": [{"body": "## Lens correctness (Opus xhigh)\nsafe to merge: yes", "createdAt": "2026-11-07T10:00:00Z"}, {"authorAssociation": "OWNER", "body": "proj ready --head abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0: arming ...", "createdAt": "2026-11-07T11:00:00Z", "includesCreatedEdit": false}], "headRefOid": "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0", "reviews": []}], "fn": "reviewed_head", "out": [true, "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0", "every lens read it"]} +{"args": [{"comments": [{"body": "## Lens correctness (Opus xhigh)\nsafe to merge: yes", "createdAt": "2026-11-07T10:00:00Z"}], "headRefOid": "abc0abc0abc0abc0abc0abc0abc0abc0abc0abc0", "reviews": []}], "fn": "reviewed_head", "out": [true, null, "the lenses did not all read one head: comment without a first-line lens header (by ? at 2026-11-07T10:00:00Z, starts with '## Lens correctness (Opus xhigh)') (no sha: no single commit of this PR)"]} From 120974a7032a6067e10c5aa8a920b4c607118fa1 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:35:03 +0100 Subject: [PATCH 18/27] fork-ci pr-queue 2/4: the queue's merge path (pr_queue.py) (#19) * fork-ci pr-queue 2/4: the queue's merge path (pr_queue.py) One run brings the oldest armed pull request that is BEHIND its base up to date: auto-merge off, gh pr update-branch, and auto-merge back on with --match-head-commit only for a new head that holds exactly the change the review read merged with the base (git merge-tree --write-tree, tree for tree). Anything else leaves it off with a comment. The plan is on disk (--state-file) before the disarm; a run stopped half-way is finished by the next run first, against the review as it is then, and no other pull request is touched until it has been re-armed or told why not. Ported from the source project's queue with its tests. Project facts come from the config; STATE names its repository; exit 3 (was 2) is "may be off and silent" and 2 is "refused to start"; the adoption of an older queue's state file is gone. The queue's own advice keeps its backticks and placeholder (lens.no_lens_text keep_markdown), which the source stripped from comments. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 2/4: trust only the queue's own files and lens reads Security review fixes for the queue's merge path. - The review counts only comments of lens_author_associations, shown and unedited (the lens.Review rule from 1/4 is wired in). A stranger's lens comment neither vouches for a head nor disarms one. - git runs with no hooks, no fsmonitor and no replace refs on every call. The cache is used only as the queue made it: a directory of this user with mode 0700, a bare repository, and the queue's own config. Anything else is set aside and rebuilt; a new cache gets no template hooks. - STATE is written 0600 (O_NOFOLLOW). recover reads it only if this user owns it and no group or other may write it; anything else is kept as STATE.bad, loudly, like an unreadable plan. - recover checks the change against the base GitHub shows now, not the base stored in the plan (the pull request may have been retargeted). - A short read gets the way out in the comment: re-post it with the full sha. - README: the queue's runner runs no pull_request job and no other repository's job, and its state and cache folders are its user's alone. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 2/4: say why a hidden comment left a PR off; rebuild a cache with grafts or alternates The queue's comment for a PR left off by a hidden lens comment names the way out (that lens posts again, or the override, also for a lens no longer used). The cache is rebuilt when it holds info/grafts, shallow or objects/info alternates, which change what its objects say and the queue never writes. Tests for the round-2 security findings: Sec2 and Sec1R2. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 2/4: git never reads a commit-graph in the queue's cache (SEC-2 r3 minor) A planted objects/info/commit-graph(s) could change the parents git reports; core.commitGraph=false joins the options every git call gets. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 1 + fork-ci/actions/pr-queue/README.md | 74 +- fork-ci/actions/pr-queue/lens.py | 18 +- fork-ci/actions/pr-queue/lens.test.py | 13 + fork-ci/actions/pr-queue/pr_queue.py | 568 +++++++++ fork-ci/actions/pr-queue/pr_queue.test.py | 1346 +++++++++++++++++++++ 6 files changed, 2011 insertions(+), 9 deletions(-) create mode 100755 fork-ci/actions/pr-queue/pr_queue.py create mode 100644 fork-ci/actions/pr-queue/pr_queue.test.py diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index c13beb6db27fc..57f9225c5ff11 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -20,6 +20,7 @@ fork-ci/actions/docs-scope/docs-scope.test.py fork-ci/actions/gradle-cache/gradle_cache.test.py fork-ci/actions/job-changed/job-changed.test.py fork-ci/actions/pr-queue/lens.test.py +fork-ci/actions/pr-queue/pr_queue.test.py # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/fork-ci/actions/pr-queue/README.md b/fork-ci/actions/pr-queue/README.md index cc374876c8b82..0cc43caa726bf 100644 --- a/fork-ci/actions/pr-queue/README.md +++ b/fork-ci/actions/pr-queue/README.md @@ -7,8 +7,8 @@ only for a head that holds exactly the change the review read. This folder is built in four stacked pull requests: -1. the review rules and the config (`lens.py`, `config.py`): this part; -2. the queue itself (`pr_queue.py`); +1. the review rules and the config (`lens.py`, `config.py`); +2. the queue itself (`pr_queue.py`, section "The queue"): this part; 3. armed pull requests that cannot merge (a red required check, or one that ended without passing or never reported) are told once and passed over; 4. the "red only because the base was red" rule, the reusable workflow @@ -92,6 +92,68 @@ heading near the top, or two `covered:`/`checks:` lines. A comment that is no report says so on its first line, `not a lens: ...`. That clears it unless one of its lines is a lens header or a `lens:` field with a sha. +## The queue + +``` +pr_queue.py --repo OWNER/NAME --config FILE --state-file ABS --cache ABS [--lock-file ABS] [--dry-run] +``` + +One run looks at the open, non-draft pull requests with auto-merge on, oldest +first, and acts on the first one it can: + +- up to date with its base: it holds the queue while its required checks run; +- behind its base: auto-merge off, `gh pr update-branch`, and auto-merge back + on for the new head only if that head is exactly the change the review read + merged with the base. Anything else leaves it off with a comment that names + the way out. + +A pull request in a merge state the queue neither updates nor waits for (a +conflict, `DIRTY`) is passed over, and the run says so. + +`gh` must be logged in for the repository (`GH_TOKEN`). Git fetches into the +queue's own bare repository (`--cache`) and gets its login from +`gh auth git-credential` only, never from a helper the machine configured. + +The token is in git's environment and `STATE` decides what is re-armed, so run +the queue on a runner (or runner group) that runs no `pull_request` job and no +other repository's job, as a user of its own. The `--state-file` and `--cache` +folders belong to that user alone. git runs with no hooks, no fsmonitor and no +replace refs, and a cache that is not that user's alone (mode `0700`), has a +config the queue did not write, or holds a file that changes what its objects +say (`info/grafts`, `shallow`, `objects/info/alternates` or +`objects/info/http-alternates`) is rebuilt, never used. A job of the same user +can still change the cache between that check and git's next read: only the +runner rule above stops that. + +| Exit | Meaning | +| ---- | ----------------------------------------------------------------------------------------------------------- | +| 0 | Done, or nothing to do (also: another run holds the lock). | +| 1 | A step failed; the next run tries again (a plan in `STATE` first). | +| 2 | Refused to start; touched nothing. Every reason is printed at once. | +| 3 | A pull request may have auto-merge off and no comment says so yet. `STATE` stays; the next run finishes it. | + +It refuses to start on a GitHub-hosted runner (its disk, and with it the plan, +goes with the job), with a `--repo` that is not `owner/name`, a relative path, +a path inside the job's workspace (the next checkout empties it), a config +error, or a git older than 2.38 (`merge-tree --write-tree`). + +`--state-file` (`STATE`) holds the plan of the run that turned auto-merge off: +`{"repo", "pr", "old", "base", "read"}` (and `"told"` once the pull request was +told the run could not finish yet). It is written to disk (mode `0600`) before +the disarm and removed when the pull request is re-armed or told why not. The +next run finishes a plan first, against the review and the base as GitHub shows +them then (a lens posted since counts; an edited one reads nothing), and +touches no other pull request until it has. A plan +whose `repo` is another repository ends the run with exit 1 and stays as it +was: give each queue its own `--state-file`. A plan without `repo` (the source +queue's format) is this repository's. An unreadable plan, or one another user +could have written (not this user's, or writable by its group or others), is +kept as `STATE.bad`, the run fails, and every later run warns while that file +exists. + +`--lock-file` (default: `STATE.lock`) keeps two runs from overlapping. +`--dry-run` takes no lock and writes nothing: no `STATE`, no cache, no comment. + ## Where the rules come from `lens.py` is a port of a production queue's review rules, behaviour for @@ -108,3 +170,11 @@ comments carry no author association: the replay gives them `OWNER` and unedited. A read shorter than 40 hex, and a lens comment edited after posting, read nothing here (54 recorded answers). The source's `full_sha`, which matched a short read to the pull request's commits, is gone. + +`pr_queue.py` is a port of the same project's queue, and `pr_queue.test.py` +holds that project's queue tests with the port's names. What the port changed: +everything project-specific comes from the config; `STATE` names its +repository; exit 3 (was 2) means "may be off and silent", so that 2 can mean +"refused"; the step that adopted an older queue's state file is gone; and the +queue's own advice in a comment keeps its backticks and `` placeholder +(`lens.no_lens_text(keep_markdown=True)`), which the source dropped. diff --git a/fork-ci/actions/pr-queue/lens.py b/fork-ci/actions/pr-queue/lens.py index abd6fdb24ee03..4e2448b718e13 100644 --- a/fork-ci/actions/pr-queue/lens.py +++ b/fork-ci/actions/pr-queue/lens.py @@ -33,6 +33,7 @@ NO_HEADER = 'comment without a first-line lens header' EDITED = 'edited after posting: post a new comment instead' HIDDEN = 'hidden: post a new comment instead' +SHORT = 'not a full 40-hex sha' EMBEDDED = 'holds a lens header line' # lens_problem: `not a lens:` cannot clear it # What a lens report looks like (ONE rule on normalised lines): on a plain line (outside code blocks and quotes) a # `lens:`, a `read at:` or `read at `, `safe to merge`, or in its first TOP_LINES plain lines a Markdown heading @@ -148,13 +149,16 @@ def loose_header(line): return bool(m and HEX_RE.search(m.group(1))) -def no_lens_text(text): +def no_lens_text(text, keep_markdown=False): """Text that can never count as a lens read, name a lens or look like a lens report (lens_problem), for anything - the queue quotes into a comment (gh errors): no `read at`, no `lens:`, no `safe to merge`.""" - text = clean_inline(text) - text = re.sub(r'\bread([\s-]*)at\b', r'read\1@', text, flags=re.I) - text = re.sub(r'\bsafe(\s+)to(\s+)merge\b', r'safe\1to\2land', text, flags=re.I) - return re.sub(r'\blens(\s*):', r'lens\1-', text, flags=re.I) + the queue quotes into a comment (gh errors): no `read at`, no `lens:`, no `safe to merge`. keep_markdown: the + queue's own advice (a command in backticks, a `` placeholder) stays as written when, read without its markdown, + it holds none of these; otherwise it is cleaned like any other text.""" + plain = clean_inline(text) + out = re.sub(r'\bread([\s-]*)at\b', r'read\1@', plain, flags=re.I) + out = re.sub(r'\bsafe(\s+)to(\s+)merge\b', r'safe\1to\2land', out, flags=re.I) + out = re.sub(r'\blens(\s*):', r'lens\1-', out, flags=re.I) + return text if keep_markdown and out == plain else out def check_state(c): @@ -314,6 +318,6 @@ def reviewed_head(self, pr): def gap(k): _, sha, why = found[k] - return why or (f'{sha[:12]}: not a full 40-hex sha' if sha else 'no sha: no single commit of this PR') + return why or (f'{sha[:12]}: {SHORT}' if sha else 'no sha: no single commit of this PR') listed = ', '.join(f'{k} {v[:12] if v else "(" + gap(k) + ")"}' for k, v in sorted(reads.items())) return True, None, f'the lenses did not all read one head: {listed}' diff --git a/fork-ci/actions/pr-queue/lens.test.py b/fork-ci/actions/pr-queue/lens.test.py index 2f3e80a1019a0..164dbb4656f93 100644 --- a/fork-ci/actions/pr-queue/lens.test.py +++ b/fork-ci/actions/pr-queue/lens.test.py @@ -95,6 +95,19 @@ def test_a_hex_word_without_a_digit_is_no_sha(self): self.assertIn(lens.EMBEDDED, SOURCE.lens_problem(post('Notes\n```\nlens: effaced1\n```'))) +class Advice(unittest.TestCase): + """no_lens_text(keep_markdown=True), for the queue's own advice: the source cleaned it like a gh error, so its + `` placeholder and the backticks around a command were dropped from the comment.""" + def test_advice_without_a_lens_word_keeps_its_markdown(self): + for text in ('push (`tool push`)', 'post the override `tool ready --head : `.'): + self.assertEqual(lens.no_lens_text(text, keep_markdown=True), text) + self.assertNotEqual(lens.no_lens_text(text), text, 'the default still cleans it') + + def test_advice_that_holds_a_lens_word_is_cleaned_like_any_text(self): # also one markdown hides + for text, out in (('`lens: x`', 'lens- x'), ('re`ad at`: x', 'read @: x'), ('**safe to merge**', 'safe to land')): + self.assertEqual(lens.no_lens_text(text, keep_markdown=True), out) + + class ProjectRules(unittest.TestCase): """What the config changes: the override comment and the bot logins.""" def test_the_configured_override_prefix_counts_and_another_does_not(self): diff --git a/fork-ci/actions/pr-queue/pr_queue.py b/fork-ci/actions/pr-queue/pr_queue.py new file mode 100755 index 0000000000000..b7731e2034db0 --- /dev/null +++ b/fork-ci/actions/pr-queue/pr_queue.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +"""A merge queue for a repository whose branch ruleset requires pull requests to be up to date with their base +(README.md). Each run brings exactly ONE waiting PR up to date: the oldest open, non-draft PR with auto-merge on that is +BEHIND its base. Its CI runs, it merges itself, and the run after the next push to the base moves the queue on. An +armed PR that is already up to date holds the queue while its required checks run. + +Auto-merge only ever merges a head someone checked. Whether GitHub checks --match-head-commit again after the head +moved is not documented, and "armed" does not say at which head (a plain push keeps auto-merge on), so the queue relies +on neither. For the PR it updates (head H1): + 1. R = the full sha the review vouched for (lens.Review.reviewed_head): the one commit every lens's newest `read at:` + names, or a newer override comment's; no lens comments: R = H1; lenses that read different commits: R unknown. + Only comments of the config's lens_author_associations count, shown and unedited: anyone can comment. + The base branch, the PR head and R are fetched into the queue's own bare repository (--cache). + 2. The plan goes into STATE (--state-file), then auto-merge OFF; if that fails, nothing else happens (if gh's error + leaves it unclear whether it is OFF, STATE stays for the next run). R unknown or not on GitHub: the PR stays OFF, + is not updated and gets a comment, so one such PR cannot stall the queue. A git fetch error changes nothing: the + PR stays as it was and the next run tries again. + 3. `gh pr update-branch`, then wait until the head moves (at most wait_seconds). + 4. Auto-merge back ON with --match-head-commit H2 only if H2 holds exactly R's change on top of the base (holds()). + Anything else (a push after the lenses, a conflict resolution, any gh or git error): it stays OFF with a comment + that it needs a lens read and the ready command. No new head in time: STATE stays, the next run checks it. +A PR already up to date never reaches these steps: the project's head_check (required) covers its head. +Stopped half-way, the next run finishes that PR first from STATE (recover), against the review and the base as GitHub +shows them then, and touches no other PR until it has re-armed it or said on it why not: a PR is never OFF and silent. +Files the queue trusts are this user's alone: STATE is written 0600 and read only if no one else could have written it; +the cache is used only as the queue made it (own_cache), and git runs there with no hooks and no replace refs. +Exit codes: 0 done; 1 a step failed, the next run tries again; 2 refused to start, nothing touched; 3 a PR may be OFF +and no comment says so yet (STATE stays, the next run finishes it). + pr_queue.py --repo OWNER/NAME --config FILE --state-file ABS --cache ABS [--lock-file ABS] [--dry-run] +""" +import argparse, fcntl, json, os, re, shutil, stat, subprocess, sys, time + +sys.dont_write_bytecode = True # no __pycache__ next to the scripts +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import config # noqa: E402 +import lens # noqa: E402 + +SHA = re.compile(r'[0-9a-f]{40}') +REPO_RE = re.compile(r'[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+') +GONE_RE = re.compile(r"not our ref|unadvertised object|couldn't find remote ref|no such remote ref", re.I) +WAITS_FOR = ('BLOCKED', 'UNSTABLE', 'CLEAN', 'HAS_HOOKS') # up-to-date merge states whose checks the queue waits for +UPDATES = 3 # holds() follows at most this many queue updates in a row +GIT_MIN = (2, 38) # git merge-tree --write-tree +REFUSED, SILENT = 2, 3 +# git gets the login from gh (GH_TOKEN, or gh's own login), never from a credential helper of the machine's git config. +GIT_AUTH = ('-c', 'credential.https://github.com.helper=', + '-c', 'credential.https://github.com.helper=!gh auth git-credential') +# Every git call, whatever the repository's files say: no hooks, no fsmonitor command, no replace refs, and no +# commit-graph (a planted objects/info/commit-graph(s) could change the parents git reports; git rebuilds nothing from it). +GIT_SAFE = ('-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.commitGraph=false', + '--no-replace-objects') +# The cache's whole config, as the queue writes it: a cache with any other config (a planted `url.*.insteadOf`, helper +# or hook path) is rebuilt, never run. +CACHE_CONFIG = '[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n' +# Files git reads that would make the cache's history or objects other than what was fetched from GitHub: grafts and +# shallow change a commit's parents, alternates adds another repository's objects. The queue never writes any of them. +UNTRUSTED_FILES = ('info/grafts', 'shallow', 'objects/info/alternates', 'objects/info/http-alternates') +VIEW = 'number,state,isDraft,autoMergeRequest,mergeStateStatus,headRefOid,baseRefName,comments,reviews,commits' +LIST = 'number,title,isDraft,autoMergeRequest,mergeStateStatus,createdAt,statusCheckRollup,headRefOid,baseRefName' + + +def holds(git, head, read, depth=UPDATES, base='refs/queue/base'): + """Step 4's rule on commits `git` (a callable running git in one repository) already has: `head` is `read`, or a + parent P of `head` is on `base` and head's tree is `git merge-tree --write-tree P X` byte for byte, where X is + `read` or head's other parent when that one holds this itself (an earlier queue update: GitHub merges the new base + into the head the queue already proved, which can be clean where a direct merge of `read` conflicts). A tree is + the same or not: a hunk moved to another function, a conflict resolution or a later push is not the same.""" + if head == read: + return True + tree = git('rev-parse', '--verify', '-q', head + '^{tree}').stdout.strip() + parents = git('rev-list', '--parents', '-n', '1', head).stdout.split()[1:] + for p in parents: + if git('merge-base', '--is-ancestor', p, base).returncode != 0: + continue + sides = [read] + [h for h in parents if h != p and depth > 1 and holds(git, h, read, depth - 1, base)] + for side in dict.fromkeys(sides): + m = git('-c', 'merge.directoryRenames=true', 'merge-tree', '--write-tree', p, side) + if m.returncode == 0 and tree and m.stdout.split()[:1] == [tree]: + return True + return False + + +def set_aside(path): + """Moves `path` out of the way (one rename, so a file inside that cannot be deleted never blocks the name), then + deletes it. True when `path` is free; a part that could not be deleted stays as `path`.old-* and is said.""" + if os.path.lexists(path): + aside = f'{path}.old-{time.time_ns()}' + try: + os.rename(path, aside) + except OSError as e: + print(f'could not move {path} aside: {e}') + return False + remove_old(aside) + return True + + +def remove_old(*paths): + """Deletes these set-aside folders and says what it could not delete (left for a person or the next run).""" + for p in paths: + failed = [] + if os.path.isdir(p) and not os.path.islink(p): + if sys.version_info >= (3, 12): + shutil.rmtree(p, onexc=lambda f, q, e: failed.append(f'{q}: {e}')) + else: + shutil.rmtree(p, onerror=lambda f, q, e: failed.append(f'{q}: {e[1]}')) + else: + try: + os.remove(p) + except OSError as e: + failed.append(f'{p}: {e}') + if failed: + print(f'could not delete all of {p} ({len(failed)} errors, first: {failed[0]}): left there') + + +def write_text(path, text): + """Via a synced temp file and os.replace: neither a kill nor a reboot mid-write leaves half a file, and a plan + written before the disarm is on disk before the disarm. Only this user may read or write it (0600).""" + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + tmp = path + '.tmp' + with open(tmp, 'w', opener=lambda p, flags: os.open(p, flags | os.O_NOFOLLOW, 0o600)) as f: + os.fchmod(f.fileno(), 0o600) # a .tmp an older run left keeps its mode otherwise + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + sync_dir(path) + + +def sync_dir(path): + """The rename or removal of `path` itself on disk: a reboot must not bring back a dropped plan or lose a new one.""" + fd = os.open(os.path.dirname(path), os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def git_problem(run=subprocess.run): + """Why this git cannot run the queue (merge-tree --write-tree needs GIT_MIN), or None.""" + try: + r = run(['git', 'version'], text=True, capture_output=True) + out = r.stdout.strip() + except OSError as e: + out = str(e) + m = re.match(r'git version (\d+)\.(\d+)', out) + if m and tuple(map(int, m.groups())) >= GIT_MIN: + return None + return f'git {".".join(map(str, GIT_MIN))} or newer is needed (merge-tree --write-tree); found: {out[:80] or "none"}' + + +class Queue: + """One repository's queue. gh, git and sleep are attributes, so tests replace them.""" + + def __init__(self, repo, cfg, state, cache, lock, sleep=time.sleep): + self.repo, self.cfg, self.state, self.cache, self.lock, self.sleep = repo, cfg, state, cache, lock, sleep + self.remote = f'https://github.com/{repo}.git' + self.review = lens.Review(cfg.bot_logins, cfg.override_prefix, cfg.override_association, + cfg.lens_author_associations) + + def gh(self, *a): + return subprocess.run(['gh', *a], text=True, capture_output=True) + + def git(self, *a): + return subprocess.run(['git', *GIT_SAFE, *GIT_AUTH, '--git-dir', self.cache, *a], text=True, capture_output=True, + env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'}) + + # --- step 1 and 4: the queue's own git repository ------------------------------------------------------------- + def cache_problem(self): + """Why the cache is not the one the queue made, or None. git runs there with the token in its environment, and + its objects decide what is re-armed: only a directory of this user that no one else can enter, whose config is + CACHE_CONFIG, that holds none of the files that make git read other objects or history (UNTRUSTED_FILES) and + that is a bare repository, counts.""" + try: + st = os.lstat(self.cache) + if not stat.S_ISDIR(st.st_mode): + return 'is not a directory' + if st.st_uid != os.getuid() or st.st_mode & 0o077: + return f'is not this user\'s alone (owner {st.st_uid}, mode {st.st_mode & 0o777:o})' + with open(os.path.join(self.cache, 'config'), opener=lambda p, f: os.open(p, f | os.O_NOFOLLOW)) as f: + if f.read() != CACHE_CONFIG: + return 'has a config the queue did not write' + for name in UNTRUSTED_FILES: + if os.path.lexists(os.path.join(self.cache, name)): + return f'holds {name}, which the queue never writes' + except (OSError, ValueError) as e: + return f'cannot be checked ({e})' + if self.git('rev-parse', '--is-bare-repository').stdout.strip() != 'true': + return 'is not a bare git repository' + return None + + def own_cache(self): + """The cache, made on first use (`git init --bare`; the first fetch brings the objects). One that is not the + queue's own (cache_problem: emptied, half-deleted, someone else's, open to others, another config) is set aside + and made again, never trusted. Every failure on the way is said and returns False (fetch then knows nothing, + so nothing is done), never an exception.""" + if os.path.lexists(self.cache): + problem = self.cache_problem() + if not problem: + # A fetch killed half-way leaves a ref lock that fails every later fetch. Only the queue uses the cache, + # and only while it holds the lock: a lock file here is always a stale one. + for d, _, files in os.walk(os.path.join(self.cache, 'refs', 'queue')): + for f in files: + if f.endswith('.lock'): + os.remove(os.path.join(d, f)) + return True + print(f'{self.cache} {problem}: rebuilding it') + parent, name = os.path.split(self.cache) + os.makedirs(parent, exist_ok=True) + remove_old(*(os.path.join(parent, f) for f in sorted(os.listdir(parent)) # CACHE.old-*, CACHE.new.old-* + if f.startswith(name + '.') and '.old-' in f)) + new = self.cache + '.new' + if not (set_aside(self.cache) and set_aside(new)): # a broken cache; one made half-way + return False + try: + os.mkdir(new, 0o700) # no one else can enter it, from the start + os.chmod(new, 0o700) + except OSError as e: + print(f'could not make {new}: {e}') + return False + r = subprocess.run(['git', *GIT_SAFE, 'init', '-q', '--bare', '--template=', new], text=True, + capture_output=True) + if r.returncode != 0: + print(f'could not make {self.cache}: {r.stderr.strip()[:300]}') + return False + try: + write_text(os.path.join(new, 'config'), CACHE_CONFIG) + os.rename(new, self.cache) + except OSError as e: # something made the cache between the two steps: the next run sets it aside + print(f'could not move the new repository to {self.cache}: {e}') + return False + return True + + def has(self, sha): + return bool(SHA.fullmatch(sha or '')) and self.git('cat-file', '-e', sha + '^{commit}').returncode == 0 + + def fetch(self, base, n, *shas): + """The base branch (refs/queue/base), PR n's head and `shas` into the cache. True when every sha is there, + False when the refs came but a sha is not on GitHub, None when nothing could be fetched (no cache, a git or + network error: nothing is known).""" + if not re.fullmatch(r'[\w./-]+', base or '') or not all(SHA.fullmatch(s or '') for s in shas): + return False + if not self.own_cache(): + return None + r = self.git('fetch', '-q', '--no-tags', self.remote, f'+refs/heads/{base}:refs/queue/base', + f'+refs/pull/{n}/head:refs/queue/pr{n}') + if r.returncode != 0: + print(f'#{n}: git fetch failed: {r.stderr.strip()[:300]}') + return None + missing = [s for s in shas if not self.has(s)] + if missing: # a read commit no ref reaches any more (GitHub keeps it) + r = self.git('fetch', '-q', '--no-tags', self.remote, *missing) + if r.returncode != 0 and not GONE_RE.search(r.stderr): # a network error is no "not on GitHub" + print(f'#{n}: git fetch of {", ".join(s[:12] for s in missing)} failed: {r.stderr.strip()[:300]}') + return None + return all(self.has(s) for s in shas) + + def same_change(self, base, n, head, read): + """Step 4's check (holds) on GitHub's commits: True, False (any other content or a git error), or None when + nothing could be fetched (the caller keeps STATE and tries again). Only full shas count.""" + if not (SHA.fullmatch(head or '') and SHA.fullmatch(read or '')): + return False + if head == read: + return True + got = self.fetch(base, n, head, read) + return holds(self.git, head, read) if got else got + + # --- steps 2-4 -------------------------------------------------------------------------------------------------- + def view(self, n): + r = self.gh('pr', 'view', str(n), '--repo', self.repo, '--json', VIEW) + try: + return json.loads(r.stdout) if r.returncode == 0 else None + except ValueError: + return None + + def new_head(self, n, old): + for _ in range(self.cfg.wait_seconds // self.cfg.poll_seconds): + head = ((self.view(n) or {}).get('headRefOid') or '').lower() + if SHA.fullmatch(head) and head != old: + return head + self.sleep(self.cfg.poll_seconds) + return None + + def leave_off(self, n, why, advice=''): + """Auto-merge stays OFF; say so on the PR. Never a `read at:` or `lens:` in it: `why` (it quotes gh errors and PR + text) is cleaned of them and of markdown; `advice`, the queue's own text from the config (a command in + backticks, a `` placeholder), keeps its markdown unless it holds one (lens.no_lens_text). 0, or SILENT when + the comment failed (the caller keeps STATE, so the next run says it).""" + why = lens.no_lens_text(why) + (' ' + lens.no_lens_text(advice, keep_markdown=True) if advice else '') + print(f'#{n}: auto-merge left OFF: {why}') + r = self.gh('pr', 'comment', str(n), '--repo', self.repo, '--body', + f'pr_queue turned auto-merge OFF: {why}\nNeeds a lens read of any new commits, then ' + f'`{self.cfg.ready(n)}`.') + if r.returncode != 0: + print(f'#{n}: could not comment either: {r.stderr.strip()[:300]}') + return SILENT + return 0 + + def read_advice(self, why): + """The way out when the review names no head, for the queue's comment.""" + if lens.NO_HEADER in why: + return ('A comment that looks like a lens report counts only with a lens header as its first plain line, ' + f'not in a code block ({self.cfg.lens_format_doc}): re-post it that way, or someone who read the ' + f'head posts the override `{self.cfg.override_prefix} : `.') + if lens.SHORT in why: + return f'A lens read names the full 40-hex sha ({self.cfg.lens_format_doc}): re-post it with that.' + if lens.HIDDEN in why: + return ('A hidden lens comment reads nothing: that lens posts a new comment, or someone who read the head ' + f'posts the override `{self.cfg.override_prefix} : ` (also for a lens no longer used).') + return '' + + def arm(self, n, head): + return self.gh('pr', 'merge', str(n), '--repo', self.repo, '--auto', f'--{self.cfg.merge_method}', + *(['--delete-branch'] if self.cfg.delete_branch else []), '--match-head-commit', head) + + def update(self, n, dry=False): + """Bring PR n up to date with its base and re-arm only the change the review read (steps 1-4).""" + cur = self.view(n) + if cur is None: + print(f'#{n}: gh cannot show it now: nothing changed, the next run tries again') + return 1 + if cur.get('state') != 'OPEN' or cur.get('isDraft') or not cur.get('autoMergeRequest') \ + or cur.get('mergeStateStatus') != 'BEHIND': + print(f'#{n} changed meanwhile (not open, armed and behind any more): left alone') + return 0 + old, base = (cur.get('headRefOid') or '').lower(), cur.get('baseRefName') or '' + reviewed, read, why = self.review.reviewed_head(cur) + read = read if reviewed else old + if dry: # writes nothing, not even the cache: it does not fetch + print(f'#{n}: would fetch {(read or "?")[:12]} into {self.cache}, turn auto-merge off, update from {base} ' + f'and re-arm if the new head stays the change read at {(read or "?")[:12]}' + + ('' if read else f' ({why})')) + return 0 + fetched = bool(read) and self.fetch(base, n, read) + if read and fetched is None: # nothing changed yet: an armed PR stays as it was, the next run tries again + print(f'#{n}: could not fetch from GitHub (see above): nothing changed, the next run tries again') + return 1 + self.write_state({'repo': self.repo, 'pr': n, 'old': old, 'base': base, 'read': read}) + code = self.update_off(n, old, base, read, fetched, why) + if code != SILENT: + self.drop_state() + return code + + def update_off(self, n, old, base, read, fetched, why): + r = self.gh('pr', 'merge', str(n), '--repo', self.repo, '--disable-auto') + if r.returncode != 0: + print(f'#{n}: could not turn auto-merge off, so not updating: {r.stderr.strip()[:300]}') + cur = self.view(n) # gh may report an error for a change GitHub applied + if cur and cur.get('state') == 'OPEN' and cur.get('autoMergeRequest'): + return 1 # still armed: nothing changed + print(f'#{n}: auto-merge may be OFF now: STATE stays, the next run finishes it') + return SILENT + if not read: + return self.leave_off(n, f'{why}, so it was not updated: which head the review vouched for is unclear.', + self.read_advice(why)) + if not fetched: + return self.leave_off(n, f'GitHub does not have the commit read at {read[:12]} (any more), so it was not ' + f'updated. Merge {base} into it yourself and push', + f'(`{self.cfg.push_command}`).') + u = self.gh('pr', 'update-branch', str(n), '--repo', self.repo) + print(u.stdout.strip() or u.stderr.strip()[:300]) + if u.returncode != 0: + return self.leave_off(n, f'gh pr update-branch failed: {u.stderr.strip()[:200]}') + head = self.new_head(n, old) + if not head: # GitHub may show the update late: the next run's recover() checks that head + print(f'#{n}: the head did not move from {old[:12]} within {self.cfg.wait_seconds} s: STATE stays, the ' + 'next run finishes it') + return SILENT + same = self.same_change(base, n, head, read) + if same is None: + print(f'#{n}: could not fetch {head[:12]} to check it: STATE stays, the next run finishes it') + return SILENT + if not same: + return self.leave_off(n, f'the new head {head[:12]} is not exactly the change read at {read[:12]} merged ' + f'with {base} (a push after the lenses, or a conflict resolution?).') + r = self.arm(n, head) + if r.returncode != 0: + return self.leave_off(n, f'turning auto-merge back on for {head[:12]} failed: {r.stderr.strip()[:200]}') + print(f'#{n}: updated to {head[:12]}, exactly the change read at {read[:12]} merged with {base}: auto-merge ' + 'back on for that head only') + return 0 + + # --- STATE and a stopped run ------------------------------------------------------------------------------------ + def write_state(self, st): + write_text(self.state, json.dumps(st)) + + def drop_state(self): + try: + os.remove(self.state) + except FileNotFoundError: + return + sync_dir(self.state) + + def tell_once(self, st, n, what): + """recover() cannot finish PR n yet: say so once on the PR (STATE `told`; the next runs only retry). 1: STATE + stays and no other PR this run.""" + if not st.get('told'): + r = self.gh('pr', 'comment', str(n), '--repo', self.repo, '--body', + f'pr_queue was stopped half-way and may have left auto-merge OFF here; {what}. The queue ' + 'retries every run and touches no other PR until it has re-armed this one or said why not.') + if r.returncode == 0: + self.write_state(dict(st, told=True)) + return 1 + + def recover(self): + """A run stopped after its disarm left STATE: re-arm the PR at a head that passes step 4's check against the + review as it is now, or leave it OFF with the comment. A PR that is armed again, merged or closed needs nothing. + 0: done, STATE removed. Otherwise STATE stays and the caller ends the run: another PR's update would overwrite + it.""" + bad = self.state + '.bad' + if os.path.exists(bad): + print(f'WARNING: {bad} is an unreadable plan of an earlier run: a PR may still have auto-merge OFF. Look ' + 'at the open PRs without auto-merge, then delete it') + mine = True + try: + with open(self.state, 'rb', opener=lambda p, f: os.open(p, f | os.O_NOFOLLOW)) as f: + info = os.fstat(f.fileno()) # a plan someone else could have written is no plan of this queue + mine = info.st_uid == os.getuid() and not info.st_mode & 0o022 + st = json.loads(f.read()) if mine else {} + except FileNotFoundError: + return 0 + except (OSError, ValueError): + st = {} + n = st.get('pr') if isinstance(st, dict) else None + if not isinstance(n, int) or isinstance(n, bool): # which PR it was is lost: never quietly + os.replace(self.state, bad) + sync_dir(bad) + print(f'ERROR: {"unreadable" if mine else "untrusted (another user could write it)"} {self.state}, kept as ' + f'{bad}: a PR may have auto-merge OFF and nothing says so') + return 1 + if st.get('repo', self.repo) != self.repo: # a plan without `repo` is an older queue's, for this repository + print(f'ERROR: {self.state} is the plan of a queue run for {st["repo"]}, not {self.repo}: two repositories ' + 'share one --state-file. Nothing touched; give each its own') + return 1 + cur = self.view(n) + if cur is None: + print(f'#{n}: a stopped run left it OFF, and gh cannot show it now: trying again next run, no other PR ' + 'meanwhile') + return self.tell_once(st, n, 'gh cannot show this PR right now') + head, base = (cur.get('headRefOid') or '').lower(), cur.get('baseRefName') or '' + open_off = cur.get('state') == 'OPEN' and not cur.get('autoMergeRequest') + # The review as it is now, not the read the stopped run stored: a lens posted since then counts. No lens + # comments: the stored read, the head that run started from. + reviewed, read, why = self.review.reviewed_head(cur) + read = read if reviewed else st.get('read') + same = self.same_change(base, n, head, read) if open_off and not cur.get('isDraft') and read else False + old = (st.get('old') or '?')[:12] + if not open_off: + code = 0 + elif not read: + code = self.leave_off(n, f'a pr_queue run that was stopped half-way or could not finish turned auto-merge ' + f'off at {old}; {why or "it stored no read commit"}, so it was not re-armed: which ' + 'head the review vouches for is unclear.', self.read_advice(why or '')) + elif same is None: # never OFF and silent: say it once on the PR, as for a gh error + print(f'#{n}: could not fetch from GitHub to check {head[:12]}: trying again next run, no other PR meanwhile') + return self.tell_once(st, n, f'the queue could not fetch {head[:12]} from GitHub to check it') + elif same: + r = self.arm(n, head) + if r.returncode == 0: + print(f'#{n}: an earlier run had turned auto-merge off; {head[:12]} is the change read at {read[:12]}: ' + 'auto-merge back on for that head only') + code = 0 + else: + code = self.leave_off(n, f'an earlier pr_queue run turned auto-merge off; turning it back on for ' + f'{head[:12]} (the change read at {read[:12]}) failed: {r.stderr.strip()[:200]}') + else: + code = self.leave_off(n, f'a pr_queue run that was stopped half-way or could not finish turned auto-merge ' + f'off at {old}; the head {head[:12]} could not be shown to be the change read at ' + f'{read[:12]}.') + if code != SILENT: + self.drop_state() + return code + + # --- one run ---------------------------------------------------------------------------------------------------- + def main(self, dry=False): + if dry: # reads only (gh views, STATE), so it needs no lock and never holds up a real run + return self.dry_run() + os.makedirs(os.path.dirname(self.lock), exist_ok=True) + with open(self.lock, 'a') as lock: # held until this run ends (the kernel drops it if the run is killed) + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + print('another pr_queue run is going: leaving it to that one') + return 0 + code = self.recover() + if code: + print('a stopped run is not finished yet: no other PR this run') + return code + return self.run(False) + + def dry_run(self): + """What a real run would do, writing nothing: no lock, no STATE, no cache, no comment, no PR change. A plan in + STATE is shown, not finished: a real run would finish it first and touch no other PR.""" + try: + with open(self.state) as f: + print(f'STATE {self.state}: {f.read()[:300]}\n a real run finishes this first (recover) and touches ' + 'no other PR until it is done; the list below is what it would do after that') + except FileNotFoundError: + pass + except OSError as e: + print(f'STATE {self.state} could not be read: {e}') + return self.run(True) + + def run(self, dry): + limit = self.cfg.list_limit + r = self.gh('pr', 'list', '--repo', self.repo, '--state', 'open', '--limit', str(limit), '--json', LIST) + try: + prs = json.loads(r.stdout) if r.returncode == 0 else None + except ValueError: + prs = None + if not isinstance(prs, list): + print(f'gh pr list failed: {r.stderr.strip()[:300] or "no JSON list"}') + return 1 + if len(prs) >= limit: + print(f'WARNING: gh listed {len(prs)} open PRs, the list_limit: GitHub lists the newest first, so the ' + 'oldest waiting ones may be missing. Raise list_limit (at most 100) or close old PRs') + for p in sorted(prs, key=lambda p: p.get('createdAt') or ''): + n = p['number'] + if p.get('isDraft') or not p.get('autoMergeRequest'): + continue + if p.get('mergeStateStatus') != 'BEHIND': + if p.get('mergeStateStatus') not in WAITS_FOR: + print(f'skip #{n}: merge state {p.get("mergeStateStatus")}, not one the queue updates or waits for') + continue + print(f'#{n} is up to date and waiting for its checks: queue is moving') + return 0 + print(f'update #{n} {(p.get("title") or "")[:70]}') + return self.update(n, dry) + print('queue empty') + return 0 + + +def cli(argv=None, env=None): + env = os.environ if env is None else env + p = argparse.ArgumentParser(prog='pr_queue.py', description='Merge queue: one waiting PR per run (README.md).') + p.add_argument('--repo', required=True, help='owner/name') + p.add_argument('--config', required=True, help='the JSON config (README.md, "Config")') + p.add_argument('--state-file', required=True, help='absolute; the plan of a run that turned auto-merge off') + p.add_argument('--cache', required=True, help='absolute; the queue\'s own bare git repository') + p.add_argument('--lock-file', help='absolute; default: the state file + .lock') + p.add_argument('--dry-run', action='store_true', help='say what a run would do; write nothing') + a = p.parse_args(argv) + lock = a.lock_file or a.state_file + '.lock' + problems = [] + if env.get('RUNNER_ENVIRONMENT') == 'github-hosted': + problems.append('this is a GitHub-hosted runner: its disk goes with the job, and with it the plan of a run that ' + 'turned auto-merge off (--state-file). Run the queue on a self-hosted runner') + if not REPO_RE.fullmatch(a.repo): + problems.append(f'--repo {a.repo!r} is not owner/name') + work = os.path.realpath(env['GITHUB_WORKSPACE']) if env.get('GITHUB_WORKSPACE') else None + for flag, path in (('--state-file', a.state_file), ('--cache', a.cache), ('--lock-file', lock)): + if not os.path.isabs(path): + problems.append(f'{flag} {path!r} is not an absolute path') + elif work and (os.path.realpath(path) + os.sep).startswith(work + os.sep): + problems.append(f'{flag} {path} is inside the job\'s workspace, which the next checkout empties') + try: + cfg = config.load(a.config) + except config.ConfigError as e: + problems.append(str(e)) + problems += filter(None, [git_problem()]) + if problems: + print('pr_queue refused to start and touched nothing:\n ' + '\n '.join(problems)) + return REFUSED + return Queue(a.repo, cfg, a.state_file, a.cache, lock).main(a.dry_run) + + +if __name__ == '__main__': + sys.stdout.reconfigure(line_buffering=True) # a run stopped half-way still shows what it did + sys.exit(cli()) diff --git a/fork-ci/actions/pr-queue/pr_queue.test.py b/fork-ci/actions/pr-queue/pr_queue.test.py new file mode 100644 index 0000000000000..91903cf6b71be --- /dev/null +++ b/fork-ci/actions/pr-queue/pr_queue.test.py @@ -0,0 +1,1346 @@ +"""Tests of pr_queue.py (run: python3 fork-ci/actions/pr-queue/pr_queue.test.py). + +The queue turns auto-merge OFF before it updates a PR from its base and turns it back on, for the new head only, when +that head is exactly the change the review read merged with the base. Every other outcome leaves it OFF with a +comment. For each step: what a rerun, a stop half-way or old state does. Most of these are the source project's queue +tests, ported (README.md, "Where the rules come from"); the rest test what changed in the port. + +Real commits in a temporary World: a local bare repository stands in for GitHub, `gh` is a fake, and nothing here can +reach the network or a real gh login (the guard below fails the whole run if anything starts the real `gh`). +""" +import atexit, os, shlex, shutil, sys, tempfile + +# --- guard: first, before anything can start gh or git ------------------------------------------------------------ +sys.dont_write_bytecode = True # no __pycache__ next to the scripts +_GUARD = tempfile.mkdtemp(prefix='pr-queue-guard-') +_GH_RAN = os.path.join(_GUARD, 'gh-ran') +os.makedirs(os.path.join(_GUARD, 'bin')) +os.makedirs(os.path.join(_GUARD, 'gh-config')) +with open(os.path.join(_GUARD, 'bin', 'gh'), 'w') as _f: + _f.write(f'#!/bin/sh\necho "$*" >> {shlex.quote(_GH_RAN)}\n' + 'echo "pr_queue.test.py: a test started the real gh" >&2\nexit 97\n') +os.chmod(os.path.join(_GUARD, 'bin', 'gh'), 0o755) +os.environ['PATH'] = os.path.join(_GUARD, 'bin') + os.pathsep + os.environ.get('PATH', '') +for _k in ('GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_HOST'): + os.environ.pop(_k, None) +os.environ.update(GH_CONFIG_DIR=os.path.join(_GUARD, 'gh-config'), GIT_ALLOW_PROTOCOL='file', + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1', GIT_TERMINAL_PROMPT='0') + + +def _gh_never_ran(): + ran = None + if os.path.exists(_GH_RAN): + with open(_GH_RAN) as f: + ran = f.read() + shutil.rmtree(_GUARD, ignore_errors=True) + if ran is not None: + print(f'FAILED: a test started the real gh: {ran[:500]}', file=sys.stderr) + sys.stderr.flush() + os._exit(97) # unittest may have said OK: this exit code is the answer + + +atexit.register(_gh_never_ran) + +import argparse, contextlib, fcntl, io, json, subprocess, unittest, unittest.mock # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import config # noqa: E402 +import lens # noqa: E402 +import pr_queue as Q # noqa: E402 + +REPO = 'owner/proj' +CFG_RAW = { + 'required_checks': ['build', 'test', 'review-check'], 'merge_method': 'squash', 'delete_branch': True, + 'bot_logins': ['review-bot'], 'lens_author_associations': ['OWNER', 'COLLABORATOR'], 'override': {'prefix': 'tool ready --head', 'author_association': 'OWNER'}, + 'ready_command': 'tool ready {pr}', 'push_command': 'tool push', 'lens_format_doc': 'docs/review.md', + 'head_check': 'review-check', +} +CFG = config.parse(json.dumps(CFG_RAW)) +REVIEW = lens.Review(CFG.bot_logins, CFG.override_prefix, CFG.override_association, CFG.lens_author_associations) +H1, H2, H3 = 'a1' * 20, 'b2' * 20, 'c3' * 20 # shas for the lens-read rules only (no git) +LENS = 'lens' # built at run time: no line of this file starts with the lens marker +READ_AT = r'(?i)read at\s*:|\blens\s*:' + + +# --- the World: real commits, a local "GitHub" ---------------------------------------------------------------------- +GIT_ENV = {'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@t', 'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@t', + 'GIT_EDITOR': 'true'} + + +class World: + """A local bare repository standing in for GitHub (every commit it holds can be fetched by sha, as on GitHub) and + a work clone that builds the commits.""" + def __init__(self): + self.dir = tempfile.mkdtemp(prefix='pr-queue-world-') + self.remote, self.work = os.path.join(self.dir, 'github.git'), os.path.join(self.dir, 'work') + self.git(self.dir, 'init', '-q', '--bare', '-b', 'main', self.remote) + self.git(self.remote, 'config', 'uploadpack.allowAnySHA1InWant', 'true') + self.git(self.dir, 'init', '-q', '-b', 'main', self.work) + self.git(self.work, 'remote', 'add', 'origin', self.remote) + self.n = 0 + + def close(self): + shutil.rmtree(self.dir, ignore_errors=True) + + @staticmethod + def git(cwd, *a): + r = subprocess.run(['git', *a], cwd=cwd, text=True, capture_output=True, env={**os.environ, **GIT_ENV}) + if r.returncode != 0: + raise RuntimeError(f'git {a}: {r.stderr}') + return r.stdout.strip() + + def commit(self, files, parents=(), msg='c'): + """A commit with exactly these files ({path: text}) and these parents; kept on "GitHub" under refs/keep/.""" + for name in os.listdir(self.work): + if name != '.git': + p = os.path.join(self.work, name) + shutil.rmtree(p) if os.path.isdir(p) else os.remove(p) + for path, text in files.items(): + os.makedirs(os.path.dirname(os.path.join(self.work, path)) or self.work, exist_ok=True) + with open(os.path.join(self.work, path), 'w') as f: + f.write(text) + self.git(self.work, 'add', '-A') + tree = self.git(self.work, 'write-tree') + sha = self.git(self.work, 'commit-tree', tree, *sum((['-p', p] for p in parents), []), '-m', msg) + return self.keep(sha) + + def merge(self, head, base): + """What `gh pr update-branch` makes: a merge commit (parents head, base) with git's own clean merge result.""" + tree = self.git(self.work, 'merge-tree', '--write-tree', head, base).split()[0] + return self.keep(self.git(self.work, 'commit-tree', tree, '-p', head, '-p', base, '-m', 'Merge main')) + + def keep(self, sha): + self.n += 1 + self.git(self.work, 'push', '-q', 'origin', f'{sha}:refs/keep/{self.n}') + return sha + + def set(self, ref, sha): # e.g. ('refs/heads/main', M1), ('refs/pull/7/head', H2) + self.git(self.remote, 'update-ref', ref, sha) + + +def make_queue(folder, cfg=CFG, sleeps=None, remote=None): + """A Queue whose STATE, cache and lock are in `folder` (never a real queue's), fetching from `remote`.""" + q = Q.Queue(REPO, cfg, os.path.join(folder, 'state', 'plan.json'), os.path.join(folder, 'cache.git'), + os.path.join(folder, 'lock', 'queue.lock'), sleep=sleeps.append if sleeps is not None else lambda s: None) + q.remote = remote or os.path.join(folder, 'no-remote.git') + return q + + +def lines(n=30, extra=None, at=None): + """x.py with n numbered lines; `extra` lines inserted after line `at`.""" + out = [f'line{i}' for i in range(1, n + 1)] + if extra: + out[at:at] = extra + return '\n'.join(out) + '\n' + + +class Killed(BaseException): + """The process dies here (the job timeout, kill -9): no except/finally of the code runs.""" + + +def ok(out=''): + return argparse.Namespace(returncode=0, stdout=out, stderr='') + + +def failed(err='HTTP 502'): + return argparse.Namespace(returncode=1, stdout='', stderr=err) + + +class QueueGh: + """`gh` for one armed PR #7 that is BEHIND main, at head `h1` (real commits of World `w`). update-branch makes the + PR head `after` (GitHub shows it after `polls` more views). `fail`: kinds whose gh call fails; `kill_at`: the kind + where the process dies ('poll': the first view after update-branch).""" + def __init__(self, w, h1, after, comments=(), polls=0, fail=(), armed=True, behind=True, kill_at=None): + self.w = w + self.head, self.after, self.armed, self.behind, self.updated = h1, after, (h1 if armed else None), behind, False + self.on_pr, self.polls, self.fail, self.kill_at = list(comments), polls, set(fail), kill_at + self.calls, self.comments = [], [] + w.set('refs/pull/7/head', h1) + + def view(self): + if self.updated and self.after: + if self.polls: + self.polls -= 1 + else: + self.head = self.after + return {'number': 7, 'state': 'OPEN', 'isDraft': False, 'baseRefName': 'main', 'headRefOid': self.head, + 'autoMergeRequest': {'mergeMethod': 'SQUASH'} if self.armed else None, + 'mergeStateStatus': 'BEHIND' if self.behind and not self.updated else 'BLOCKED', 'title': 't', + 'createdAt': '2026-11-07T00:00:00Z', 'statusCheckRollup': [], 'comments': self.on_pr, 'reviews': [], + 'commits': self.commits(), 'labels': []} + + def commits(self): # the PR's commits as GitHub lists them: head's history that main lacks + out = self.w.git(self.w.remote, 'rev-list', self.head, '^refs/heads/main') + return [{'oid': sha} for sha in reversed(out.split())] + + @staticmethod + def kind(a): + if a[:2] == ('pr', 'merge'): + return 'disarm' if '--disable-auto' in a else 'arm' + return {('pr', 'view'): 'view', ('pr', 'comment'): 'comment', ('pr', 'update-branch'): 'update', + ('pr', 'list'): 'list'}.get(tuple(a[:2]), ' '.join(a[:2])) + + def __call__(self, *a): + self.calls.append(a) + kind = self.kind(a) + if kind == self.kill_at or (self.kill_at == 'poll' and kind == 'view' and self.updated): + raise Killed(kind) + if kind in self.fail: + return failed('HTTP 502, read at: ' + 'c3' * 20) + if kind == 'view': + return ok(json.dumps(self.view())) + if kind == 'list': + return ok(json.dumps([self.view()])) + if kind == 'disarm': + self.armed = None + elif kind == 'update': + self.updated = True + if self.after: + self.w.set('refs/pull/7/head', self.after) + elif kind == 'arm': + self.armed = a[a.index('--match-head-commit') + 1] + elif kind == 'comment': + self.comments.append(a[a.index('--body') + 1]) + return ok() + + def order(self): + return [k for k in map(self.kind, self.calls) if k != 'view'] + + +def note(body, when, login='owner', association='OWNER', edited=False): + """A comment as gh shows it: by default the owner's, never edited.""" + return {'body': body, 'createdAt': when, 'author': {'login': login}, 'authorAssociation': association, + 'includesCreatedEdit': edited} + + +def lens_at(name, sha, when='2026-11-07T10:00:00Z'): + return note(f'{LENS}: {name} · reviewer: x · read at: {sha}\nfindings: none', when) + + +def override(sha, when): # an override comment as the project's ready command posts it + return dict(note(f'tool ready --head {sha}: arming auto-merge for this head although these lenses did not read it: ' + f'x. Why:\n\n> read it myself', when), authorAssociation='OWNER', includesCreatedEdit=False) + + +def run_quiet(fn, *a): + with contextlib.redirect_stdout(io.StringIO()) as out: + code = fn(*a) + return code, out.getvalue() + + +class Base(unittest.TestCase): + """main M0 -> M1 (adds lines at the top of x.py: other line numbers for the PR's change); the PR's change C on M0; + up = what update-branch makes of C (merge of M1); resolved: a merge of M1 whose resolution changed the PR's + lines.""" + def setUp(self): + w = self.w = World() + self.addCleanup(w.close) + self.sleeps = [] + self.q = make_queue(w.dir, sleeps=self.sleeps, remote=w.remote) + self.m0 = w.commit({'x.py': lines()}) + self.m1 = w.commit({'x.py': lines(extra=['main 1', 'main 2'], at=0)}, [self.m0]) + w.set('refs/heads/main', self.m1) + self.c = w.commit({'x.py': lines(extra=['new'], at=20)}, [self.m0]) + self.up = w.merge(self.c, self.m1) + self.resolved = w.commit({'x.py': lines(extra=['main 1', 'main 2'], at=0).replace('line21', 'new, resolved\nline21')}, + [self.c, self.m1]) + + def fake(self, after='up', **kw): + return QueueGh(self.w, kw.pop('h1', self.c), getattr(self, after) if after else None, **kw) + + def update(self, fake, dry=False, q=None): + q = q or self.q + q.gh = fake + return run_quiet(q.update, 7, dry) + + def plan_exists(self): + return os.path.exists(self.q.state) + + +class Update(Base): + def assertOff(self, f, code, said): + self.assertIsNone(f.armed, 'auto-merge must stay OFF') + self.assertNotIn('arm', f.order()) + self.assertEqual(len(f.comments), 1, f.comments) + self.assertEqual(code, 0) + self.assertNotRegex(f.comments[0], r'(?i)read at\s*:', 'a queue comment must never count as a lens read') + self.assertIn(said, f.comments[0]) + self.assertIn('`tool ready 7`', f.comments[0]) + post = {'body': f.comments[0], 'author': {'login': 'owner'}} + self.assertIsNone(REVIEW.lens_problem(post)) + self.assertEqual(REVIEW.reviewed_head({'comments': [post], 'reviews': []}), (False, None, 'no lens comments')) + + def test_same_change_rearms_at_the_new_head_only(self): # main moved the PR's lines: still the same change + f = self.fake(polls=2) # GitHub shows the new head a few polls later + code, out = self.update(f) + self.assertEqual(code, 0, out) + self.assertEqual(f.order(), ['disarm', 'update', 'arm']) + self.assertEqual(f.armed, self.up) + arm = next(a for a in f.calls if QueueGh.kind(a) == 'arm') + self.assertEqual(arm[arm.index('--match-head-commit') + 1], self.up) + self.assertEqual(self.sleeps, [CFG.poll_seconds] * 2) + self.assertEqual(f.comments, []) + self.assertFalse(self.plan_exists()) + + def test_disarm_comes_before_the_update(self): + f = self.fake() + self.update(f) + self.assertLess(f.order().index('disarm'), f.order().index('update')) + + def test_a_failed_disarm_means_no_update(self): # stopped there: the PR is as before (armed, behind); rerun retries + f = self.fake(fail=('disarm',)) + code, out = self.update(f) + self.assertEqual(code, 1) + self.assertEqual(f.order(), ['disarm']) + self.assertEqual((f.armed, f.updated, f.comments), (self.c, False, [])) + self.assertFalse(self.plan_exists(), 'still armed: nothing to finish') + + def test_a_disarm_github_applied_despite_an_error_keeps_the_plan(self): # gh's error does not say it failed + f = self.fake() + + def gh(*a): + r = f(*a) + return failed('HTTP 502') if QueueGh.kind(a) == 'disarm' else r + code, out = self.update(gh) + self.assertEqual((code, f.armed, f.updated, f.comments), (Q.SILENT, None, False, []), out) + self.assertTrue(self.plan_exists(), 'OFF and nothing says so yet: the next run finishes it') + code, out = run_quiet(self.q.recover) + self.assertEqual((code, f.armed, f.comments), (0, self.c, []), out) + + def test_a_conflict_resolution_stays_off_and_comments(self): + f = self.fake('resolved') + code, out = self.update(f) + self.assertOff(f, code, 'is not exactly the change read') + self.assertIn('conflict resolution', f.comments[0]) + + def test_no_new_head_in_time_keeps_the_plan_for_the_next_run(self): # GitHub may show the update late + f = self.fake(after=None) + code, out = self.update(f) + self.assertEqual((code, f.armed, f.comments), (Q.SILENT, None, []), out) + self.assertEqual(sum(self.sleeps), CFG.wait_seconds) + self.assertTrue(self.plan_exists(), 'the next run finishes it from STATE') + f.head = self.up # GitHub shows the update now + code, out = run_quiet(self.q.recover) + self.assertEqual((code, f.armed, f.comments), (0, self.up, []), out) + + def test_a_new_head_with_other_content_stays_off(self): # a force-push meanwhile, not a merge of main + self.other = self.w.commit({'x.py': lines(extra=['other'], at=20)}, [self.m1]) + f = self.fake('other') + self.assertOff(f, self.update(f)[0], 'is not exactly the change read') + + def test_a_merge_of_a_commit_that_is_not_on_main_stays_off(self): # its content was never on main, nor read + side = self.w.commit({'x.py': lines(extra=['main 1', 'main 2'], at=0), 'evil.sh': 'rm -rf /\n'}, [self.m1]) + self.side = self.w.merge(self.c, side) + f = self.fake('side') + self.assertOff(f, self.update(f)[0], 'is not exactly the change read') + + def test_a_fetch_error_changes_nothing(self): # nothing is known yet, the next run tries again + self.q.remote = os.path.join(self.w.dir, 'no-such-repo.git') # never fetched: the cache does not exist yet + f = self.fake() + code, out = self.update(f) + self.assertEqual((code, f.armed, f.comments, f.order()), (1, self.c, [], []), out) + self.assertFalse(self.plan_exists()) + + def test_a_read_commit_that_github_does_not_have_stays_off_and_is_not_updated(self): + f = self.fake(comments=[lens_at('correctness', 'de' * 20, 't')]) + code, out = self.update(f) + self.assertOff(f, code, 'GitHub does not have the commit read @') + self.assertIn('`tool push`', f.comments[0]) + self.assertNotIn('update', f.order()) + + def test_a_network_error_on_the_fetch_by_sha_is_no_gone_commit(self): # None (nothing known), not False + real = self.q.git + + def git(*a): + if a[:1] == ('fetch',) and not any(x.startswith('+refs/') for x in a): + return argparse.Namespace(returncode=128, stdout='', stderr='fatal: unable to access: timed out') + return real(*a) + self.w.set('refs/pull/7/head', self.c) + self.q.git = git + self.assertIsNone(run_quiet(self.q.fetch, 'main', 7, self.c, 'de' * 20)[0]) + self.q.git = real # the real "not our ref" of a missing commit: False + self.assertIs(run_quiet(self.q.fetch, 'main', 7, 'de' * 20)[0], False) + + def test_a_headerless_lens_comment_says_how_to_repost(self): + fence = '`' * 3 + f = self.fake(comments=[note(f'{fence}\n{LENS}: correctness · reviewer: x · read at: {self.c[:7]}\n{fence}', 't')]) + code, out = self.update(f) + self.assertOff(f, code, 'unclear') + self.assertIn('re-post it', f.comments[0]) + self.assertIn('docs/review.md', f.comments[0]) + self.assertIn('`tool ready --head : `', f.comments[0]) + + def test_failed_update_or_arm_stays_off(self): + f = self.fake(fail=('update',)) + self.assertOff(f, self.update(f)[0], 'update-branch failed') + f = self.fake(fail=('arm',)) + self.update(f) + self.assertIsNone(f.armed) + self.assertIn('back on', f.comments[0]) + self.assertNotRegex(f.comments[0], r'(?i)read at\s*:', 'the quoted gh error had one') + + def test_a_failed_comment_keeps_the_state(self): # still OFF and silent: the run fails, the next run says it + f = self.fake('resolved', fail=('comment',)) + self.assertEqual(self.update(f)[0], Q.SILENT) + self.assertIsNone(f.armed) + with open(self.q.state) as s: + self.assertEqual({k: v for k, v in json.load(s).items() if k in ('repo', 'pr')}, {'repo': REPO, 'pr': 7}) + + def test_a_pr_gh_cannot_show_is_left_as_it_was(self): # the port says why (the source said "changed meanwhile") + f = self.fake(fail=('view',)) + code, out = self.update(f) + self.assertEqual((code, f.order(), f.armed), (1, [], self.c)) + self.assertIn('gh cannot show it now', out) + self.assertNotIn('changed meanwhile', out) + + def test_rerun_after_any_stop_touches_nothing(self): + # stopped after the disarm (before or after update-branch): no longer armed, so left alone + for f in (self.fake(armed=False), self.fake(armed=False, behind=False)): + code, out = self.update(f) + self.assertEqual((code, f.order(), f.armed), (0, [], None)) + # stopped after the re-arm, or a second run right after a finished one: up to date, armed at up, left alone + f = self.fake() + self.update(f) + f.calls.clear() + code, out = self.update(f) + self.assertEqual((code, f.order(), f.armed), (0, [], self.up)) + + def test_dry_run_changes_nothing(self): + f = self.fake() + code, out = self.update(f, dry=True) + self.assertEqual((code, f.order(), f.armed), (0, [], self.c)) + self.assertFalse(os.path.exists(self.q.cache), 'no fetch, so no git cache made') + self.assertIn('would fetch', out) + + def test_dry_run_leaves_an_existing_cache_as_it_was(self): # a real run may use it at the same time + self.w.set('refs/pull/7/head', self.c) + self.assertTrue(run_quiet(self.q.fetch, 'main', 7, self.c)[0]) + cache = self.q.cache + snap = lambda: sorted((os.path.relpath(os.path.join(d, x), cache), os.path.getmtime(os.path.join(d, x))) + for d, _, files in os.walk(cache) for x in files) + before = snap() + self.w.set('refs/heads/main', self.up) # GitHub moved on: a fetch would write new objects and refs + self.update(self.fake(), dry=True) + self.assertEqual(snap(), before) + + def test_the_read_head_itself_is_the_same_change(self): # recover: the run died before update-branch + self.assertTrue(self.q.same_change('main', 7, self.c, self.c)) + self.assertFalse(self.q.same_change('main', 7, self.c, self.c[:7]), 'only full shas') + + +class Main(Base): + def main(self, fake, dry=False, q=None): + q = q or self.q + q.gh = fake + return run_quiet(q.main, dry) + + @staticmethod + def listed(n=7, state='BEHIND', armed=True, rollup=()): + return {'number': n, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'mergeMethod': 'SQUASH'} if armed else None, + 'mergeStateStatus': state, 'createdAt': f'2026-11-07T00:00:0{n % 10}Z', 'statusCheckRollup': list(rollup), + 'headRefOid': 'ab' * 20, 'baseRefName': 'main'} + + @staticmethod + def listing(fake, prs): + def gh(*a): + if a[:2] == ('pr', 'list'): + fake.calls.append(a) + return ok(json.dumps(prs)) + return fake(*a) + return gh + + def test_the_oldest_behind_pr_goes_through_update(self): + f = self.fake() + code, out = self.main(self.listing(f, [self.listed()])) + self.assertEqual((code, f.armed), (0, self.up), out) + + def test_a_rerun_after_a_left_off_pr_moves_on(self): # the disarmed PR is skipped, it does not stall the queue + code, out = self.main(self.listing(self.fake(), [self.listed(3, armed=False), self.listed(4, 'CLEAN')])) + self.assertIn('#4 is up to date', out) + + def test_an_empty_queue_does_nothing(self): + f = self.fake() + code, out = self.main(self.listing(f, [])) + self.assertEqual((code, f.order(), f.armed), (0, ['list'], self.c), out) + self.assertIn('queue empty', out) + code, out = self.main(self.listing(f, [self.listed(3, armed=False), dict(self.listed(4), isDraft=True)])) + self.assertEqual(code, 0) + self.assertIn('queue empty', out, 'no armed, ready PR: nothing to do') + + def test_a_failed_or_odd_listing_fails_the_run(self): + for r in (failed('HTTP 502'), ok('{"not": "a list"}'), ok('not json')): + with self.subTest(r=r): + code, out = self.main(lambda *a: r) + self.assertEqual(code, 1, out) + self.assertIn('gh pr list failed', out) + + def test_a_full_listing_warns_that_old_prs_may_be_missing(self): # GitHub lists the newest PRs first + q = make_queue(self.w.dir, config.parse(json.dumps({**CFG_RAW, 'list_limit': 2})), remote=self.w.remote) + f = self.fake() + code, out = self.main(self.listing(f, [self.listed(3, armed=False), self.listed(4, armed=False)]), q=q) + self.assertEqual(code, 0) + self.assertIn('WARNING: gh listed 2 open PRs, the list_limit', out) + list_call = next(a for a in f.calls if QueueGh.kind(a) == 'list') + self.assertEqual(list_call[list_call.index('--limit') + 1], '2') + code, out = self.main(self.listing(f, [self.listed(3, armed=False)]), q=q) + self.assertNotIn('WARNING', out) + + def test_a_dry_run_takes_no_lock_and_shows_a_stopped_plan(self): # it reads only: it never holds up a real run + self.q.write_state({'repo': REPO, 'pr': 5, 'old': self.c, 'base': 'main', 'read': self.c}) + os.makedirs(os.path.dirname(self.q.lock), exist_ok=True) + with open(self.q.lock, 'w') as held: + fcntl.flock(held, fcntl.LOCK_EX) # a real run is going + f = self.fake() + code, out = self.main(self.listing(f, [self.listed()]), dry=True) + self.assertEqual((code, f.order(), f.armed), (0, ['list'], self.c), out) + self.assertIn('a real run finishes this first', out) + self.assertIn('#7: would fetch', out) + self.assertTrue(self.plan_exists(), 'the plan is shown, not finished or removed') + self.assertFalse(os.path.exists(self.q.cache)) + + def test_a_merge_state_the_queue_does_not_handle_is_said(self): # skipped, never silently + f = self.fake() + code, out = self.main(self.listing(f, [self.listed(3, 'DIRTY')])) + self.assertEqual((code, f.order(), f.armed), (0, ['list'], self.c), out) + self.assertIn('skip #3: merge state DIRTY', out) + + def test_a_held_lock_runs_nothing(self): # two runs at once (a push and the timer): the second does nothing + os.makedirs(os.path.dirname(self.q.lock), exist_ok=True) + with open(self.q.lock, 'w') as held: + fcntl.flock(held, fcntl.LOCK_EX) + f = self.fake() + code, out = self.main(self.listing(f, [self.listed()])) + self.assertEqual((code, f.calls), (0, [])) + self.assertIn('another pr_queue run', out) + + +class ReviewedHead(unittest.TestCase): # which head the queue keeps + def test_one_sha_for_every_lens(self): + pr = {'headRefOid': H2, 'commits': [{'oid': H1}, {'oid': H2}], + 'comments': [lens_at('correctness', H1, 't1'), lens_at('data-integrity', H1, 't2')]} + self.assertEqual(REVIEW.reviewed_head(pr)[:2], (True, H1), 'an earlier commit of the PR') + for short in (7, 12, 39): # even of the head itself: a force-push can bring a commit with the same prefix + pr = {'headRefOid': H1, 'comments': [lens_at('correctness', H1[:short], 't1'), lens_at('di', H1, 't2')]} + self.assertEqual(REVIEW.reviewed_head(pr)[:2], (True, None), short) + + def test_lenses_that_read_different_heads_give_no_head(self): + pr = {'headRefOid': H2, 'commits': [{'oid': H1}, {'oid': H2}], + 'comments': [lens_at('correctness', H1, 't1'), lens_at('data-integrity', H2, 't2')]} + reviewed, sha, why = REVIEW.reviewed_head(pr) + self.assertEqual((reviewed, sha), (True, None)) + self.assertIn('did not all read one head', why) + + def test_an_override_covers_only_the_lenses_older_than_it(self): + lenses = [lens_at('correctness', H1, '2026-11-07T10:00:00Z'), lens_at('di', H1, '2026-11-07T10:00:01Z')] + pr = {'headRefOid': H2, 'commits': [{'oid': H1}, {'oid': H2}], 'comments': lenses + [override(H2, '2026-11-07T11:00:00Z')]} + self.assertEqual(REVIEW.reviewed_head(pr)[:2], (True, H2)) + pr['comments'].append(lens_at('correctness r2', H2, '2026-11-07T12:00:00Z')) # a re-check after it + self.assertEqual(REVIEW.reviewed_head(pr)[:2], (True, H2), 'correctness and the override say H2: one head') + pr['comments'][-1] = lens_at('correctness r2', H3, '2026-11-07T12:00:00Z') + self.assertEqual(REVIEW.reviewed_head(pr)[1], None, 'a newer lens read of another head is not covered') + + def test_no_lens_comments_is_not_reviewed(self): + self.assertEqual(REVIEW.reviewed_head({'comments': [override(H2, 't')]})[:2], (False, None)) + + def test_an_override_quoted_or_by_a_bot_does_not_count(self): + base = [lens_at('correctness', H1, '2026-11-07T10:00:00Z')] + quoted = note('> ' + override(H2, 'x')['body'], '2026-11-07T11:00:00Z') + bot = dict(override(H2, '2026-11-07T11:00:00Z'), author={'login': 'review-bot'}) + for extra in (quoted, bot): + self.assertEqual(REVIEW.reviewed_head({'headRefOid': H1, 'comments': base + [extra]})[1], H1) + + +class QueueKeepsTheReadChange(Base): + def test_lenses_on_different_heads_stay_off_and_are_not_updated(self): + other = self.w.commit({'x.py': lines(extra=['other'], at=20)}, [self.m0]) + f = self.fake(comments=[lens_at('correctness', self.c, 't1'), lens_at('di', other, 't2')]) + code, out = self.update(f) + self.assertEqual((f.armed, f.updated), (None, False), out) + self.assertIn('did not all read one head', f.comments[0]) + self.assertNotRegex(f.comments[0], READ_AT) + + def test_the_head_of_an_override_is_re_armed_after_a_clean_update(self): + f = self.fake(comments=[lens_at('correctness', self.m0, '2026-11-07T10:00:00Z'), + override(self.c, '2026-11-07T11:00:00Z')]) + code, out = self.update(f) + self.assertEqual((code, f.armed), (0, self.up), out) + + def test_a_second_update_keeps_the_change_read_two_merges_ago(self): # lenses read C; the queue already merged M1 + m2 = self.w.commit({'x.py': lines(extra=['main 1', 'main 2', 'main 3'], at=0)}, [self.m1]) + self.w.set('refs/heads/main', m2) + self.up2 = self.w.merge(self.up, m2) + f = self.fake('up2', h1=self.up, comments=[lens_at('correctness', self.c, 't1'), lens_at('di', self.c, 't2')]) + code, out = self.update(f) + self.assertEqual((code, f.armed), (0, self.up2), out + str(f.comments)) + + def test_no_state_is_left_after_a_finished_update(self): + for f in (self.fake(), self.fake('resolved'), self.fake(fail=('update',))): + self.update(f) + self.assertFalse(self.plan_exists()) + + +class Recover(Base): # a run killed after its disarm + def recover(self, fake, state, q=None): + q = q or self.q + Q.write_text(q.state, state if isinstance(state, str) else json.dumps(state)) # as the queue writes it + q.gh = fake + return run_quiet(q.recover) + + def plan(self, **kw): + return dict({'repo': REPO, 'pr': 7, 'old': self.c, 'base': 'main', 'read': self.c}, **kw) + + def saved_plan(self): + with open(self.q.state) as s: + return json.load(s) + + def test_armed_again_or_closed_needs_nothing(self): + f = self.fake() # still armed: the run was killed before its disarm, or someone re-armed it + code, out = self.recover(f, self.plan()) + self.assertEqual((code, [f.kind(c) for c in f.calls], f.armed), (0, ['view'], self.c)) + self.assertFalse(self.plan_exists()) + + def test_off_at_the_same_change_is_re_armed_for_that_head(self): + f = self.fake(armed=False) # killed before update-branch: the head is still the read commit + code, out = self.recover(f, self.plan()) + self.assertEqual((code, f.armed, f.comments), (0, self.c, []), out) + f = self.fake(armed=False, h1=self.up) # killed after update-branch + code, out = self.recover(f, self.plan()) + self.assertEqual((code, f.armed, f.comments), (0, self.up, []), out) + + def test_a_plan_without_repo_from_the_source_queue_is_finished(self): # the source project's STATE format + legacy = {k: v for k, v in self.plan().items() if k != 'repo'} + f = self.fake(armed=False, h1=self.up) + code, out = self.recover(f, legacy) + self.assertEqual((code, f.armed, f.comments), (0, self.up, []), out) + self.assertFalse(self.plan_exists()) + + def test_a_plan_of_another_repository_is_refused_and_touches_nothing(self): # two queues share one --state-file + theirs = self.plan(repo='someone/else') + f = self.fake(armed=False, h1=self.up) + code, out = self.recover(f, theirs) + self.assertEqual((code, f.calls, f.armed), (1, [], None), out) + self.assertIn('ERROR', out) + self.assertIn('someone/else', out) + self.assertEqual(self.saved_plan(), theirs, 'that queue still needs its plan') + f.calls.clear() + code, out = Main.main(self, Main.listing(f, [Main.listed()])) + self.assertEqual((code, f.calls), (1, []), 'the run ends: no PR of this repository either') + + def test_off_at_another_change_or_without_a_plan_says_so(self): + for h1, plan in ((self.resolved, self.plan()), (self.c, self.plan(read=None)), (self.c, self.plan(read=self.c[:7]))): + f = self.fake(armed=False, h1=h1) + code, out = self.recover(f, plan) + self.assertEqual((f.armed, len(f.comments)), (None, 1), (plan, out)) + self.assertIn('stopped half-way', f.comments[0]) + self.assertFalse(self.plan_exists()) + + def test_a_lens_posted_after_the_stop_counts(self): # recover reads the review again, not the stored read + c1 = self.w.commit({'x.py': lines(extra=['new 1'], at=20)}, [self.m0]) + c2 = self.w.commit({'x.py': lines(extra=['new 1', 'new 2'], at=20)}, [c1]) + up2 = self.w.merge(c2, self.m1) + before = [lens_at('correctness', c2, '2026-11-07T10:00:00Z'), lens_at('di', c2, '2026-11-07T10:00:01Z')] + # the stopped run read c2; since then both lenses re-read and vouch only for the older c1 + after = [lens_at('correctness r2', c1, '2026-11-07T12:00:00Z'), lens_at('di r2', c1, '2026-11-07T12:00:01Z')] + f = self.fake(armed=False, h1=up2, comments=before + after) + code, out = self.recover(f, self.plan(old=c2, read=c2)) + self.assertEqual((f.armed, len(f.comments)), (None, 1), out) + self.assertIn(c1[:12], f.comments[0], 'the read the lenses vouch for now') + self.assertFalse(self.plan_exists()) + # lenses that now disagree: no head is vouched for, so it stays OFF and says why + f = self.fake(armed=False, h1=up2, comments=before + after[:1]) + code, out = self.recover(f, self.plan(old=c2, read=c2)) + self.assertEqual((f.armed, len(f.comments)), (None, 1), out) + self.assertIn('did not all read one head', f.comments[0]) + self.assertNotRegex(f.comments[0], READ_AT) + # every lens re-read the updated head itself: that head is re-armed + f = self.fake(armed=False, h1=up2, comments=before + [lens_at('correctness r2', up2, '2026-11-07T12:00:00Z'), + lens_at('di r2', up2, '2026-11-07T12:00:01Z')]) + code, out = self.recover(f, self.plan(old=c2, read=c2)) + self.assertEqual((f.armed, f.comments), (up2, []), out) + + def test_a_lens_comment_edited_after_the_stop_reads_nothing_until_that_lens_posts_again(self): + lens_comment = lens_at('correctness', self.c, '2026-11-07T10:00:00Z') + lens_comment['includesCreatedEdit'] = True # GitHub says it changed, not what: `read at` may name another head + f = self.fake(armed=False, h1=self.up, comments=[lens_comment]) + code, out = self.recover(f, self.plan()) + self.assertEqual((f.armed, len(f.comments)), (None, 1), out) + self.assertIn('edited after posting', f.comments[0]) + self.assertNotRegex(f.comments[0], READ_AT) + f = self.fake(armed=False, h1=self.up, comments=[lens_comment, lens_at('correctness', self.c, '2026-11-07T11:00Z')]) + code, out = self.recover(f, self.plan()) + self.assertEqual((f.armed, f.comments), (self.up, []), 'a new comment of that lens: ' + out) + + def test_a_gh_error_keeps_the_state_and_says_so_once(self): + f = self.fake(armed=False, fail=('view',)) + code, out = self.recover(f, self.plan()) + self.assertEqual((code, len(f.comments)), (1, 1)) + self.assertTrue(self.plan_exists()) + code, out = self.recover(f, self.saved_plan()) + self.assertEqual((code, len(f.comments)), (1, 1), 'a second failing run does not comment again') + self.assertNotRegex(f.comments[0], READ_AT) + + def test_a_fetch_error_that_does_not_heal_says_so_once(self): # never OFF and silent + self.q.remote = os.path.join(self.w.dir, 'no-such-repo.git') + f = self.fake(armed=False, h1=self.up) # killed after update-branch: the new head must be fetched + for _ in range(3): + code, out = self.recover(f, self.saved_plan() if self.plan_exists() else self.plan()) + self.assertEqual((code, f.armed, len(f.comments)), (1, None, 1), out) + self.assertTrue(self.plan_exists()) + self.assertNotRegex(f.comments[0], READ_AT) + + def test_a_stale_ref_lock_in_the_cache_is_removed(self): # a git fetch killed half-way + self.w.set('refs/pull/7/head', self.c) + self.assertTrue(run_quiet(self.q.fetch, 'main', 7, self.c)[0]) + open(os.path.join(self.q.cache, 'refs', 'queue', 'pr7.lock'), 'w').close() + self.assertTrue(run_quiet(self.q.fetch, 'main', 7, self.up)[0]) + + def stuck_folder(self, path): + """`path` with a file inside that cannot be deleted (its folder is read-only); made writable again at the end.""" + if os.geteuid() == 0: + self.skipTest('root deletes a file in a read-only folder: no undeletable file to test with') + stuck = os.path.join(path, 'stuck') + os.makedirs(stuck) + open(os.path.join(stuck, 'f'), 'w').close() + os.chmod(stuck, 0o555) + self.addCleanup(lambda: [os.chmod(os.path.join(d, 'stuck'), 0o755) for d in self.leftovers() + [path] + if os.path.isdir(os.path.join(d, 'stuck'))]) + + def leftovers(self): + parent, name = os.path.split(self.q.cache) + return [os.path.join(parent, f) for f in os.listdir(parent) if f.startswith(name + '.') and '.old-' in f] + + def fetch(self, sha): + return run_quiet(self.q.fetch, 'main', 7, sha) + + def test_a_broken_cache_with_an_undeletable_file_is_set_aside(self): + self.w.set('refs/pull/7/head', self.c) + self.stuck_folder(self.q.cache) # not a bare git repo + got, out = self.fetch(self.c) + self.assertTrue(got, out) + self.assertIn(': rebuilding it', out) + self.assertIn('could not delete all of', out, 'the part left behind is said, not silent') + self.assertEqual(len(self.leftovers()), 1) + self.assertTrue(self.fetch(self.c)[0], 'the next run uses the new cache') + + def test_a_half_made_cache_with_an_undeletable_file_is_set_aside(self): # a `git init` killed half-way + self.w.set('refs/pull/7/head', self.c) + self.stuck_folder(self.q.cache + '.new') + got, out = self.fetch(self.c) + self.assertTrue(got, out) + self.assertIn('could not delete all of', out) + self.assertEqual(len(self.leftovers()), 1) + + def test_leftovers_are_removed_by_the_next_rebuild(self): # a stop between the move and the delete + os.makedirs(self.q.cache + '.old-1/objects') + os.makedirs(self.q.cache + '.new.old-2/objects') + os.makedirs(self.q.cache) # broken: empty + self.w.set('refs/pull/7/head', self.c) + got, out = self.fetch(self.c) + self.assertEqual((got, self.leftovers()), (True, []), out) + + def test_a_cache_that_cannot_be_moved_aside_is_said_and_nothing_is_known(self): # fetch None: nothing changes + if os.geteuid() == 0: + self.skipTest('root renames in a read-only folder') + ro = os.path.join(self.w.dir, 'ro') + self.q.cache = os.path.join(ro, 'cache.git') + os.makedirs(self.q.cache) + os.chmod(ro, 0o555) + self.addCleanup(os.chmod, ro, 0o755) + got, out = self.fetch(self.c) + self.assertIsNone(got, out) + self.assertIn('could not move', out) + + def test_the_plan_is_this_users_alone(self): # STATE decides what is re-armed + self.assertEqual(self.update(self.fake(after=None))[0], Q.SILENT) + self.assertEqual(os.stat(self.q.state).st_mode & 0o777, 0o600) + + def test_a_plan_another_user_could_write_is_kept_aside_and_not_followed(self): + f = self.fake(armed=False, h1=self.up) + Q.write_text(self.q.state, json.dumps(self.plan())) + os.chmod(self.q.state, 0o664) # group-writable: anyone in the group could have written this plan + self.q.gh = f + code, out = run_quiet(self.q.recover) + self.assertEqual((code, f.calls, f.armed), (1, [], None), out) + self.assertIn('ERROR: untrusted', out) + self.assertTrue(os.path.exists(self.q.state + '.bad')) + self.assertFalse(self.plan_exists()) + + def test_the_base_comes_from_github_not_from_the_plan(self): # retargeted since the stop, or a wrong plan + release = self.w.commit({'x.py': lines(extra=['release'], at=25)}, [self.m0]) + self.w.set('refs/heads/release', release) + f = self.fake(armed=False, h1=self.up) + view = f.view + f.view = lambda: dict(view(), baseRefName='release') # GitHub: the PR now merges into release + code, out = self.recover(f, self.plan()) + self.assertEqual((f.armed, len(f.comments)), (None, 1), 'proved against main, merging into release: ' + out) + f = self.fake(armed=False, h1=self.up) + code, out = self.recover(f, self.plan(base='release')) + self.assertEqual((code, f.armed, f.comments), (0, self.up, []), out) + + def test_a_failed_comment_keeps_the_state(self): + f = self.fake(armed=False, h1=self.resolved, fail=('comment',)) + self.assertEqual(self.recover(f, self.plan())[0], Q.SILENT) + self.assertTrue(self.plan_exists()) + + def test_an_unfinished_recover_ends_the_run(self): # no other PR may overwrite the STATE + f = self.fake(armed=False, fail=('view',)) + self.q.write_state(self.plan()) + self.q.gh = f + self.assertEqual(run_quiet(self.q.main)[0], 1) + self.assertNotIn('list', f.order()) + self.assertTrue(self.plan_exists()) + + def test_an_unreadable_state_is_kept_aside_and_never_silent(self): # which PR it was is lost + bad = self.q.state + '.bad' + for junk in ('{', '[]', '{"pr": "7"}', ''): # '': a file a reboot cut short + f = self.fake() + code, out = self.recover(f, junk) + self.assertEqual((code, f.calls), (1, []), 'this run is red and touches no PR') + self.assertIn('ERROR: unreadable', out) + self.assertFalse(self.plan_exists()) + with open(bad) as b: + self.assertEqual(b.read(), junk) + self.q.gh = self.fake() + code, out = run_quiet(self.q.recover) + self.assertEqual(code, 0, 'the next runs go on') + self.assertIn('WARNING: ' + bad, out, 'and say it every run while the file is there') + + def test_the_plan_survives_a_new_process_and_an_emptied_lock_folder(self): # STATE is its own file + f = self.fake(after=None) # the run stops with the PR OFF: GitHub did not show the new head in time + self.assertEqual(self.update(f)[0], Q.SILENT) + shutil.rmtree(os.path.dirname(self.q.lock), ignore_errors=True) # e.g. a lock in /tmp, gone after a reboot + q = make_queue(self.w.dir, remote=self.w.remote) # the next run: a new process, the same --state-file + f.head = self.up + q.gh = f + code, out = run_quiet(q.main) + self.assertEqual(code, 0, out) + self.assertEqual((f.armed, f.comments), (self.up, []), 'the next run finishes the PR') + self.assertFalse(self.plan_exists()) + + +class WhoseCommentsCount(Base): + """Anyone can comment on a public repository: only comments of the configured lens associations, shown and never + edited, are lens reads (lens.Review.counts). Everyone else's neither vouch for a head nor block one.""" + + def stranger(self, body, association='NONE'): + return note(body, '2026-11-07T11:00:00Z', login='drive-by', association=association) + + def test_a_strangers_lens_comment_never_moves_the_read_head(self): + reviewed = self.w.commit({'x.py': lines(extra=['reviewed'], at=20)}, [self.m0]) + # the lens read `reviewed`; a write-access push made the head C (auto-merge stays on); a stranger "reads" C + for association in ('NONE', 'CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'MEMBER'): + forged = self.stranger(f'{LENS}: correctness · reviewer: x · read at: {self.c}', association) + f = self.fake(comments=[lens_at('correctness', reviewed), forged]) + code, out = self.update(f) + self.assertEqual((f.armed, len(f.comments)), (None, 1), f'{association}: {self.c[:12]} re-armed: {out}') + + def test_a_strangers_junk_comment_cannot_disarm_a_reviewed_pr(self): + for junk in ('lgtm, safe to merge', f'looks fine, read at: {self.c[:8]}'): + f = self.fake(comments=[lens_at('correctness', self.c), self.stranger(junk)]) + code, out = self.update(f) + self.assertEqual((code, f.armed, f.comments), (0, self.up, []), junk + out) + + def test_an_edited_lens_comment_does_not_count(self): # its author may have moved `read at` to an unread head + f = self.fake(comments=[dict(lens_at('correctness', self.c), includesCreatedEdit=True)]) + code, out = self.update(f) + self.assertEqual((f.armed, f.updated, len(f.comments)), (None, False, 1), out) + self.assertIn('edited after posting', f.comments[0]) + + def test_a_hidden_lens_comment_reads_nothing(self): + hidden = dict(lens_at('correctness', self.c, '2026-11-07T11:00:00Z'), isMinimized=True) + f = self.fake(comments=[lens_at('correctness', self.m0), hidden]) + code, out = self.update(f) + self.assertIsNone(f.armed, 'the hidden read of C counted: ' + out) + + def test_a_lens_comment_whose_author_does_not_count_is_named_in_the_run_log(self): + forged = self.stranger(f'{LENS}: correctness · reviewer: x · read at: {self.c}', 'MEMBER') + code, out = self.update(self.fake(comments=[lens_at('correctness', self.c), forged])) + self.assertIn('author association MEMBER is not one of lens_author_associations (OWNER, COLLABORATOR)', out) + + +class Sec2(Base): + """Security review round 2: hidden comments. Hiding old review rounds as Outdated is routine; it must not make a + reviewed PR look unreviewed, since the queue vouches for the head of an unreviewed one.""" + + def test_all_lens_comments_hidden_as_outdated_does_not_vouch_for_an_unread_push(self): + reviewed = self.w.commit({'x.py': lines(extra=['reviewed'], at=20)}, [self.m0]) + # the lens read `reviewed`; a write-access push made the head C (auto-merge stays on); round 1 is hidden + f = self.fake(comments=[dict(lens_at('correctness', reviewed), isMinimized=True)]) + code, out = self.update(f) + self.assertEqual((code, f.armed, f.updated, len(f.comments)), (0, None, False, 1), out) + self.assertIn('hidden', f.comments[0]) + self.assertIn('`tool ready --head : `', f.comments[0]) + + def test_a_hidden_override_does_not_count(self): + reviewed = self.w.commit({'x.py': lines(extra=['reviewed'], at=20)}, [self.m0]) + hidden = dict(override(self.c, '2026-11-07T11:00:00Z'), isMinimized=True) + f = self.fake(comments=[lens_at('correctness', reviewed), hidden]) + code, out = self.update(f) + self.assertEqual((f.armed, len(f.comments)), (None, 1), out) + self.assertIn('is not exactly the change read', f.comments[0]) # held against the lens's read, not C + + +class Sec1R2(Base): + """Security review 1, round 2: an override taken back by hiding it vouches for nothing.""" + + def test_a_hidden_override_vouches_for_nothing(self): + reviewed = self.w.commit({'x.py': lines(extra=['reviewed'], at=20)}, [self.m0]) + shown = override(self.c, '2026-11-07T11:00:00Z') + f = self.fake(comments=[lens_at('correctness', reviewed), shown]) + self.assertEqual(self.update(f)[0], 0) + self.assertEqual(f.armed, self.up, 'the shown override vouches for C') + f = self.fake(comments=[lens_at('correctness', reviewed), dict(shown, isMinimized=True)]) + code, out = self.update(f) + self.assertIsNone(f.armed, 'the hidden override re-armed C: ' + out) + + +class OwnCache(Base): + """git runs in the cache with the token in its environment, and the cache's objects decide what is re-armed: only + the cache as the queue made it is used, and no hook runs there (a job on the same runner could plant one).""" + + def fetch(self, sha): + self.w.set('refs/pull/7/head', sha) + return run_quiet(self.q.fetch, 'main', 7, sha) + + def test_a_new_cache_is_this_users_alone_with_the_queues_config(self): + self.assertTrue(self.fetch(self.c)[0]) + self.assertEqual(os.stat(self.q.cache).st_mode & 0o777, 0o700) + with open(os.path.join(self.q.cache, 'config')) as f: + self.assertEqual(f.read(), Q.CACHE_CONFIG) + self.assertFalse(os.path.exists(os.path.join(self.q.cache, 'hooks')), 'no sample hooks either') + + def test_a_planted_hook_never_runs(self): + self.assertTrue(self.fetch(self.c)[0]) + marker = os.path.join(self.w.dir, 'hook-ran') + os.makedirs(os.path.join(self.q.cache, 'hooks'), exist_ok=True) + for name in ('reference-transaction', 'post-index-change', 'fsmonitor-watchman'): + path = os.path.join(self.q.cache, 'hooks', name) + with open(path, 'w') as h: + h.write(f'#!/bin/sh\necho {name} >> {shlex.quote(marker)}\n') + os.chmod(path, 0o755) + got, out = self.fetch(self.up) # moves refs/queue/pr7: a ref transaction + self.assertTrue(got, out) + self.assertFalse(os.path.exists(marker), 'a hook in the cache ran with the token in its environment') + + def test_a_cache_with_grafts_shallow_or_alternates_is_rebuilt(self): # each changes what the objects say + for name in ('info/grafts', 'shallow', 'objects/info/alternates', 'objects/info/http-alternates'): + self.assertTrue(self.fetch(self.c)[0]) + path = os.path.join(self.q.cache, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + f.write(f'{self.c} {self.m1}\n' if name in ('info/grafts', 'shallow') else self.w.dir + '\n') + got, out = self.fetch(self.up) + self.assertTrue(got, out) + self.assertIn(f'holds {name}, which the queue never writes: rebuilding it', out) + self.assertFalse(os.path.lexists(path), name) + + def test_a_cache_with_another_config_or_open_to_others_is_rebuilt(self): + self.assertTrue(self.fetch(self.c)[0]) + with open(os.path.join(self.q.cache, 'config'), 'a') as f: # fetch "GitHub" from somewhere else + f.write(f'[url "{os.path.join(self.w.dir, "elsewhere.git")}"]\n\tinsteadOf = {self.q.remote}\n') + got, out = self.fetch(self.up) + self.assertTrue(got, out) + self.assertIn('has a config the queue did not write: rebuilding it', out) + os.chmod(self.q.cache, 0o770) + got, out = self.fetch(self.c) + self.assertTrue(got, out) + self.assertIn("is not this user's alone", out) + self.assertEqual(os.stat(self.q.cache).st_mode & 0o777, 0o700) + + +# --- the source project's lens-review cases for the queue ------------------------------------------------------------ +HEAD = 'def wipe(player, island):\n' +READ = HEAD + " if is_admin(player):\n log.info('wipe')\n wipe_island(island)\n" # only an admin wipes +DEDENTED = HEAD + " if is_admin(player):\n log.info('wipe')\n wipe_island(island)\n" # every player does +EXTRA = READ + ' refund_all(island)\n' # a real code change pushed later + + +class Admin(unittest.TestCase): + """main M0 -> M1 (another file changed); P: the read change on M0.""" + def setUp(self): + w = self.w = World() + self.addCleanup(w.close) + self.q = make_queue(w.dir, remote=w.remote) + self.m0 = w.commit({'w.py': HEAD, 'm.txt': 'a\n'}) + self.m1 = w.commit({'w.py': HEAD, 'm.txt': 'b\n'}, [self.m0]) + w.set('refs/heads/main', self.m1) + self.p = w.commit({'w.py': READ, 'm.txt': 'a\n'}, [self.m0]) + w.set('refs/pull/7/head', self.p) + + def push(self, parent, text): + return self.w.commit({'w.py': text, 'm.txt': 'a\n'}, [parent]) + + def update(self, fake): + self.q.gh = fake + return run_quiet(self.q.update, 7) + + def main(self, fake, q=None): + q = q or self.q + q.gh = fake + return run_quiet(q.main) + + +class OnlyWhatALensReadIsReArmed(Admin): + """GitHub's auto-merge request records no head, and GitHub keeps auto-merge on after a push by someone with write + access, so after a plain push, a web conflict resolution or an applied suggestion the PR is armed at a head no lens + read. The queue must not re-arm that head plus the base with a fresh pin.""" + + def test_a_push_after_ready_is_not_re_armed(self): + w = self.w + h1 = self.push(self.p, EXTRA) + f = QueueGh(w, h1, w.merge(h1, self.m1), comments=[lens_at('data-integrity', self.p), + lens_at('correctness', self.p)]) + code, out = self.update(f) + self.assertIsNone(f.armed, f'the queue armed a head with a push no lens read (lenses read {self.p[:7]}): {out}') + self.assertEqual(len(f.comments), 1, 'auto-merge left off must say so on the PR') + + def test_control_the_head_the_lenses_read_is_still_re_armed(self): + f = QueueGh(self.w, self.p, self.w.merge(self.p, self.m1), comments=[lens_at('correctness', self.p)]) + self.update(f) + self.assertEqual(f.armed, f.after) + + def test_control_a_pr_without_lens_comments_is_still_re_armed(self): # no review: the head it found merges on green + f = QueueGh(self.w, self.p, self.w.merge(self.p, self.m1)) + self.update(f) + self.assertEqual(f.armed, f.after) + + +class WhitespaceIsPartOfTheChange(Admin): + """The queue compares whole trees, whitespace included: a re-indent (Python, YAML) or a space inside a command is + another change.""" + + def test_a_python_dedent_is_a_different_change(self): + dedented = self.w.commit({'w.py': DEDENTED, 'm.txt': 'a\n'}, [self.m0]) + self.assertFalse(self.q.same_change('main', 7, self.w.merge(dedented, self.m1), self.p), + 'wipe_island moved out of the admin check, taken as the change read') + + def test_a_space_in_a_shell_command_is_a_different_change(self): + a = self.w.commit({'w.py': HEAD, 'm.txt': 'a\n', 'c.sh': 'set -eu\nrm -rf "${DIR:?}"/cache\n'}, [self.m0]) + b = self.w.commit({'w.py': HEAD, 'm.txt': 'a\n', 'c.sh': 'set -eu\nrm -rf "${DIR:?}"/ cache\n'}, [self.m0]) + self.assertFalse(self.q.same_change('main', 7, self.w.merge(b, self.m1), a)) + + def test_main_moving_lines_still_counts_as_the_same_change(self): + m2 = self.w.commit({'w.py': '# header\n# more\n' + HEAD, 'm.txt': 'b\n'}, [self.m1]) # main adds lines above + self.w.set('refs/heads/main', m2) + self.assertTrue(self.q.same_change('main', 7, self.w.merge(self.p, m2), self.p)) + + def test_a_whitespace_only_push_meanwhile_is_not_re_armed(self): + # P was read. Between the queue's view and update-branch, someone pushes a dedent on top of P; update-branch + # merges the base into THAT head. + pushed = self.push(self.p, DEDENTED) + f = QueueGh(self.w, self.p, self.w.merge(pushed, self.m1), comments=[lens_at('correctness', self.p)]) + code, out = self.update(f) + self.assertNotEqual(f.armed, f.after, 'armed a head where every player wipes islands: ' + out) + + def test_a_reindented_python_line_is_not_the_same_change(self): + base = 'def f(x):\n if x:\n y = 1\n return y\n' + m0 = self.w.commit({'x.py': base, 'm.txt': 'a\n'}) + read = self.w.commit({'x.py': base.replace(' y = 1\n', ' y = 1\n return check(x)\n'), + 'm.txt': 'a\n'}, [m0]) + after = self.w.commit({'x.py': base.replace(' y = 1\n', ' y = 1\n return check(x)\n'), + 'm.txt': 'a\n'}, [m0]) # now inside the block above + m1 = self.w.commit({'x.py': base, 'm.txt': 'b\n'}, [m0]) + self.w.set('refs/heads/main', m1) + h2 = self.w.merge(after, m1) + self.w.set('refs/pull/7/head', h2) + self.assertTrue(self.q.same_change('main', 7, self.w.merge(read, m1), read), 'control: the read change + main') + self.assertFalse(self.q.same_change('main', 7, h2, read), 'a re-indented Python line passed as the change read') + + +class AStopHalfWayLeavesATrace(Admin): + """Killed after the disarm (a job timeout; no gh call has a timeout of its own), the next run must re-arm the PR at + a checked head or say on it that it is OFF: never OFF and silent, skipped for good.""" + + def test_killed_after_the_disarm_then_the_next_run_re_arms_or_says_it_is_off(self): + for kill_at in ('update', 'poll', 'arm'): + with self.subTest(kill_at=kill_at): + f = QueueGh(self.w, self.p, self.w.merge(self.p, self.m1), kill_at=kill_at) + with self.assertRaises(Killed): + self.main(f) + f.kill_at = None + q = make_queue(self.w.dir, remote=self.w.remote) # the next run is a new process + self.main(f, q) + self.assertTrue(f.armed == f.head or f.comments, + f'killed at {kill_at}, then a rerun: auto-merge OFF at {f.head[:12]} and nothing on ' + 'the PR says so') + self.assertFalse(os.path.exists(q.state)) + + +ISLAND = [' island = load(island_id)', ' if island is None:', ' return False', + " log.info('island %s', island_id)", ' save(island)', ' return True'] +CHECK = ' require_owner(player, island)' + + +def islands(check_in=None, top=()): + """w.py: two copy-pasted handlers with the same lines; `check_in` gets the owner check after its 3rd line; `top`: + lines main added at the top (other line numbers for everything below).""" + out = list(top) + for fn in ('wipe_island', 'visit_island'): + out += [f'def {fn}(player, island_id):'] + ISLAND[:3] + ([CHECK] if fn == check_in else []) + ISLAND[3:] + ['', ''] + return '\n'.join(out) + '\n' + + +class Islands(unittest.TestCase): + def setUp(self): + w = self.w = World() + self.addCleanup(w.close) + self.q = make_queue(w.dir, remote=w.remote) + self.m0 = w.commit({'w.py': islands()}) + self.m1 = w.commit({'w.py': islands(top=['import log', '', ''])}, [self.m0]) + w.set('refs/heads/main', self.m1) + self.read = w.commit({'w.py': islands('wipe_island')}, [self.m0]) # what the lenses read + + def update(self, fake): + self.q.gh = fake + return run_quiet(self.q.update, 7) + + +class AMovedHunkIsAnotherChange(Islands): + """A line moved into another function with the same context lines (copy-pasted handlers) is another change: the + owner check moved from wipe_island to visit_island lets every player wipe islands.""" + + def test_the_owner_check_moved_to_another_function_is_not_re_armed(self): + moved = self.w.commit({'w.py': islands('visit_island')}, [self.read]) # pushed after ready, kept armed + f = QueueGh(self.w, moved, self.w.merge(moved, self.m1), + comments=[lens_at('correctness', self.read), lens_at('data-integrity', self.read)]) + code, out = self.update(f) + self.assertIsNone(f.armed, 'armed a head whose owner check sits in visit_island, not wipe_island: ' + out) + + def test_control_the_read_change_merged_with_main_is_re_armed(self): + up = self.w.merge(self.read, self.m1) + f = QueueGh(self.w, self.read, up, comments=[lens_at('correctness', self.read)]) + self.update(f) + self.assertEqual(f.armed, up) + + +class ShortReadsNameNoHead(Islands): + """A read shorter than 40 hex names nothing, even of the head itself: after a force-push, a new commit can be made + to share the prefix of the dropped one that was read. Left OFF, the comment says how to post the read.""" + + def test_a_short_read_of_the_head_is_left_off_with_the_way_out(self): + up = self.w.merge(self.read, self.m1) + f = QueueGh(self.w, self.read, up, + comments=[lens_at('correctness', self.read[:7]), lens_at('data-integrity', self.read[:12])]) + code, out = self.update(f) + self.assertEqual((f.armed, f.updated, len(f.comments)), (None, False, 1), out) + self.assertIn(lens.SHORT, f.comments[0]) + self.assertIn('re-post it with that', f.comments[0]) + self.assertNotRegex(f.comments[0], READ_AT) + + def test_control_full_reads_of_one_head_are_re_armed(self): + up = self.w.merge(self.read, self.m1) + f = QueueGh(self.w, self.read, up, + comments=[lens_at('correctness', self.read), lens_at('data-integrity', self.read)]) + code, out = self.update(f) + self.assertEqual(f.armed, up, out + str(f.comments)) + + def test_control_reads_of_two_heads_still_give_no_head(self): + other = H1[:7] + 'f' * 33 + pr = {'headRefOid': H1, 'commits': [{'oid': H1}, {'oid': other}], + 'comments': [lens_at('correctness', H1), lens_at('data-integrity', other)]} + self.assertIsNone(REVIEW.reviewed_head(pr)[1]) + + +class TwoPrs: + """gh for PR #7 (a QueueGh) and PR #9: open, not armed, at h9, left OFF by an earlier stopped run. + `view_fails`: how many `pr view 9` calls fail first.""" + def __init__(self, seven, h9, view_fails=0): + self.seven, self.h9, self.view_fails, self.armed9, self.comments9 = seven, h9, view_fails, None, [] + + def view9(self): + return {'number': 9, 'state': 'OPEN', 'isDraft': False, 'baseRefName': 'main', 'headRefOid': self.h9, + 'autoMergeRequest': {'mergeMethod': 'SQUASH'} if self.armed9 else None, 'mergeStateStatus': 'BLOCKED', + 'title': 'nine', 'createdAt': '2026-11-06T00:00:00Z', 'statusCheckRollup': [], + 'comments': [], 'reviews': [], 'commits': [{'oid': self.h9}], 'labels': []} + + def __call__(self, *a): + if a[:2] == ('pr', 'list'): + self.seven.calls.append(a) + return ok(json.dumps([self.view9(), self.seven.view()])) + if not (a[:1] == ('pr',) and len(a) > 2 and a[2] == '9'): + return self.seven(*a) + if a[1] == 'view': + if self.view_fails: + self.view_fails -= 1 + return failed() + return ok(json.dumps(self.view9())) + if a[1] == 'comment': + self.comments9.append(a[a.index('--body') + 1]) + elif a[1] == 'merge': + self.armed9 = None if '--disable-auto' in a else a[a.index('--match-head-commit') + 1] + return ok() + + +class AFailedRecoverIsNotDropped(Islands): + """recover() keeps STATE when gh cannot show the PR; the run must then end, or the next PR's update overwrites + STATE and the stopped PR stays OFF and silent.""" + + def test_pr_9_left_off_by_a_stopped_run_is_re_armed_or_commented_after_a_gh_error(self): + h9 = self.w.commit({'n.py': 'x\n'}, [self.m1]) + seven = QueueGh(self.w, self.read, self.w.merge(self.read, self.m1), comments=[lens_at('correctness', self.read)]) + f = TwoPrs(seven, h9, view_fails=1) + self.q.write_state({'repo': REPO, 'pr': 9, 'old': h9, 'base': 'main', 'read': h9}) # killed after PR 9's disarm + self.q.gh = f + run_quiet(self.q.main) # recover: `pr view 9` fails (HTTP 502) + self.assertTrue(os.path.exists(self.q.state), 'STATE for PR 9 was dropped') + self.assertEqual((seven.armed, seven.updated), (self.read, False), 'PR 7 was updated meanwhile') + run_quiet(self.q.main) # the next run: gh works again + self.assertTrue(f.armed9 == h9 or f.comments9, 'PR 9: OFF since the stopped run, never re-armed, no comment') + self.assertFalse(os.path.exists(self.q.state)) + + +# --- what the port adds ---------------------------------------------------------------------------------------------- +class Arm(unittest.TestCase): + def armed_with(self, **change): + d = tempfile.mkdtemp(prefix='pr-queue-arm-') + self.addCleanup(shutil.rmtree, d, True) + q = make_queue(d, config.parse(json.dumps({**CFG_RAW, **change}))) + calls = [] + q.gh = lambda *a: calls.append(a) or ok() + q.arm(7, H1) + return calls[0] + + def test_the_merge_method_and_branch_deletion_come_from_the_config(self): + self.assertEqual(self.armed_with(), ('pr', 'merge', '7', '--repo', REPO, '--auto', '--squash', '--delete-branch', + '--match-head-commit', H1)) + self.assertEqual(self.armed_with(merge_method='merge', delete_branch=False), + ('pr', 'merge', '7', '--repo', REPO, '--auto', '--merge', '--match-head-commit', H1)) + self.assertIn('--rebase', self.armed_with(merge_method='rebase')) + + +class GitCalls(unittest.TestCase): + def test_git_gets_the_login_from_gh_only_and_never_prompts(self): + d = tempfile.mkdtemp(prefix='pr-queue-git-') + self.addCleanup(shutil.rmtree, d, True) + q = make_queue(d) + seen = [] + with unittest.mock.patch.object(Q.subprocess, 'run', lambda argv, **kw: seen.append((argv, kw)) or ok()): + q.git('fetch', 'x') + (argv, kw), = seen + self.assertEqual(argv[:13], ['git', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', + '-c', 'core.commitGraph=false', '--no-replace-objects', + '-c', 'credential.https://github.com.helper=', + '-c', 'credential.https://github.com.helper=!gh auth git-credential', '--git-dir']) + self.assertEqual(argv[13:], [q.cache, 'fetch', 'x']) + self.assertEqual(kw['env']['GIT_TERMINAL_PROMPT'], '0') + + def test_the_empty_helper_resets_any_helper_the_machine_set(self): # git's rule, checked on this git + d = tempfile.mkdtemp(prefix='pr-queue-git-') + self.addCleanup(shutil.rmtree, d, True) + subprocess.run(['git', 'init', '-q', '--bare', d], check=True) + r = subprocess.run(['git', '-c', 'credential.https://github.com.helper=store', *Q.GIT_AUTH, '--git-dir', d, + 'config', '--get-all', 'credential.https://github.com.helper'], text=True, capture_output=True) + self.assertEqual(r.stdout.splitlines(), ['store', '', '!gh auth git-credential'], + 'the empty value must come after any machine helper and before gh: git then asks only gh') + + +class GitVersion(unittest.TestCase): + @staticmethod + def problem(out=None, exc=None): + def run(argv, **kw): + if exc: + raise exc + return ok(out) + return Q.git_problem(run) + + def test_merge_tree_write_tree_needs_2_38(self): + self.assertIn('git 2.38 or newer is needed', self.problem('git version 2.34.1\n')) + self.assertIn('2.34.1', self.problem('git version 2.34.1\n')) + for fine in ('git version 2.38.0', 'git version 2.47.1.windows.1', 'git version 3.0.0'): + self.assertIsNone(self.problem(fine), fine) + + def test_no_git_or_odd_output_is_a_problem(self): + self.assertIn('No such file', self.problem(exc=FileNotFoundError(2, 'No such file or directory'))) + self.assertIn('found: none', self.problem('')) + self.assertIsNotNone(self.problem('version 9')) + + def test_this_git_is_new_enough(self): # the tests above need merge-tree --write-tree too + self.assertIsNone(Q.git_problem()) + + +class Cli(unittest.TestCase): + """cli() refuses with exit 2 and builds no Queue when it cannot run safely; it names every problem at once.""" + def setUp(self): + self.d = tempfile.mkdtemp(prefix='pr-queue-cli-') + self.addCleanup(shutil.rmtree, self.d, True) + self.config = os.path.join(self.d, 'queue.json') + with open(self.config, 'w') as f: + json.dump(CFG_RAW, f) + self.built = [] + + def cli(self, *extra, repo=REPO, state=None, cache=None, env=None, git=None): + test = self + + class Built: + def __init__(self, *a): + self.a = a + + def main(self, dry): + test.built.append((self.a, dry)) + return 0 + argv = [f'--repo={repo}', '--config', self.config, '--state-file', state or os.path.join(self.d, 'run', 'plan.json'), + '--cache', cache or os.path.join(self.d, 'cache.git'), *extra] + with unittest.mock.patch.object(Q, 'Queue', Built), unittest.mock.patch.object(Q, 'git_problem', lambda: git): + return run_quiet(Q.cli, argv, env if env is not None else {}) + + def refused(self, *why, **kw): + code, out = self.cli(**kw) + self.assertEqual((code, self.built), (Q.REFUSED, []), out) + self.assertIn('refused to start and touched nothing', out) + for w in why: + self.assertIn(w, out) + return out + + def test_a_good_call_builds_the_queue(self): + self.assertEqual(self.cli()[0], 0) + (args, dry), = self.built + state = os.path.join(self.d, 'run', 'plan.json') + self.assertEqual((args[0], args[2], args[3], args[4], dry), + (REPO, state, os.path.join(self.d, 'cache.git'), state + '.lock', False)) + self.assertEqual(args[1], CFG) + self.cli('--dry-run', '--lock-file', os.path.join(self.d, 'l'), env={'RUNNER_ENVIRONMENT': 'self-hosted'}) + self.assertEqual((self.built[1][0][4], self.built[1][1]), (os.path.join(self.d, 'l'), True)) + + def test_a_github_hosted_runner_is_refused(self): # its disk, and the plan on it, goes with the job + self.refused('GitHub-hosted runner', env={'RUNNER_ENVIRONMENT': 'github-hosted'}) + + def test_a_repo_that_is_not_owner_name_is_refused(self): + for repo in ('proj', 'owner/proj/x', '-x/proj', 'owner/pr oj', ''): + with self.subTest(repo=repo): + self.built.clear() + self.refused('is not owner/name', repo=repo) + + def test_relative_paths_are_refused(self): + self.refused("--state-file 'plan.json' is not an absolute path", "--cache 'cache.git' is not an absolute path", + state='plan.json', cache='cache.git') + code, out = self.cli('--lock-file', 'l') + self.assertEqual((code, self.built), (Q.REFUSED, []), out) + self.assertIn("--lock-file 'l' is not an absolute path", out) + + def test_paths_in_the_job_workspace_are_refused(self): # the next checkout empties it + ws = os.path.join(self.d, 'ws') + out = self.refused('--state-file', 'inside the job\'s workspace', state=os.path.join(ws, 'plan.json'), + env={'GITHUB_WORKSPACE': ws}) + self.assertIn('--lock-file', out, 'the default lock is next to the plan') + code, out = self.cli(state=os.path.join(self.d, 'ws2', 'plan.json'), env={'GITHUB_WORKSPACE': ws}) + self.assertEqual(code, 0, 'a sibling folder whose name starts the same is not inside: ' + out) + + def test_a_config_error_is_refused(self): + with open(self.config, 'w') as f: + json.dump({**CFG_RAW, 'required_check': ['build']}, f) + self.refused("unknown key 'required_check'") + os.remove(self.config) + self.refused('cannot read it') + + def test_an_old_git_is_refused(self): + self.refused('git 2.38 or newer', git='git 2.38 or newer is needed (merge-tree --write-tree); found: git 2.34') + + def test_every_problem_is_named_at_once(self): + os.remove(self.config) + self.refused('GitHub-hosted', 'is not owner/name', 'not an absolute path', 'cannot read it', 'git 2.38', + repo='x', state='p', env={'RUNNER_ENVIRONMENT': 'github-hosted'}, git='git 2.38 or newer') + + +if __name__ == '__main__': + unittest.main() From 9031591aadb9f1e3064755503440d5299805ecba Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:43:09 +0100 Subject: [PATCH 19/27] fork-ci pr-queue 3/4: armed pull requests that cannot merge are told and passed over (#20) * fork-ci pr-queue 3/4: armed pull requests that cannot merge are told and passed over An armed pull request whose required check is red (its newest run failed, or was cancelled after a red), or that is up to date while a required check ended without passing (CANCELLED, STARTUP_FAILURE, STALE...) or never reported with no workflow running on its head any more, cannot merge, and nothing re-runs that check. It is told why once per head, check and reason, and the queue passes over it instead of waiting behind it. Ported from the source project's queue with its tests. One change: a check that never reported is told to push or merge the base, not to re-run. A re-run repeats the jobs of the workflow files the head was built with, so it cannot add a check that was added or renamed since (test test_a_check_that_never_reported_is_not_told_to_re_run, red on the source's advice). Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 3/4: only the queue's own comment marks a red PR as told Security review fix. On a public repository anyone can comment, so a copy of the queue's "required check ... is red on " text, posted first by someone else, silenced the queue's notice for that head. Told now means a comment the queue's own login posted (viewerDidAuthor) that starts with the mark. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- fork-ci/actions/pr-queue/README.md | 30 ++- fork-ci/actions/pr-queue/pr_queue.py | 121 ++++++++++- fork-ci/actions/pr-queue/pr_queue.test.py | 245 ++++++++++++++++++++++ 3 files changed, 391 insertions(+), 5 deletions(-) diff --git a/fork-ci/actions/pr-queue/README.md b/fork-ci/actions/pr-queue/README.md index 0cc43caa726bf..9f6c4cadf9f1a 100644 --- a/fork-ci/actions/pr-queue/README.md +++ b/fork-ci/actions/pr-queue/README.md @@ -8,9 +8,10 @@ only for a head that holds exactly the change the review read. This folder is built in four stacked pull requests: 1. the review rules and the config (`lens.py`, `config.py`); -2. the queue itself (`pr_queue.py`, section "The queue"): this part; +2. the queue itself (`pr_queue.py`, section "The queue"); 3. armed pull requests that cannot merge (a red required check, or one that - ended without passing or never reported) are told once and passed over; + ended without passing or never reported) are told once and passed over: + this part; 4. the "red only because the base was red" rule, the reusable workflow `.github/workflows/fork-pr-queue.yml`, `action.yml` and the usage sections of this file. @@ -110,6 +111,25 @@ first, and acts on the first one it can: A pull request in a merge state the queue neither updates nor waits for (a conflict, `DIRTY`) is passed over, and the run says so. +An armed pull request that cannot merge does not hold the queue. It is told +why on the pull request, once per head, check and reason, and passed over: + +- a required check whose newest run failed (`FAILURE`, `ERROR`, `TIMED_OUT`, + or `CANCELLED` after a red with nothing green since), behind or not. Its + author fixes it; nothing in the queue re-runs it. +- up to date, and a required check's newest run ended without passing and + without a red (`CANCELLED`, `STARTUP_FAILURE`, `STALE`, ...): nothing + re-runs it by itself. The comment says to re-run the CI run or push. +- up to date, and a required check never reported although no workflow runs + on the head any more. The comment says to push or merge the base: a re-run + repeats the jobs of the workflow files the head was built with, so it cannot + add a check those files do not have (one added or renamed since). + +Only the newest run of a check counts, and a check not in `required_checks` is +ignored. Only the queue's own comment (posted with its token) counts as told: +a copy of it by anyone else does not silence it. When GitHub cannot say which workflows still run on the head, the +pull request is not called stuck: it holds the queue, and the run says why. + `gh` must be logged in for the repository (`GH_TOKEN`). Git fetches into the queue's own bare repository (`--cache`) and gets its login from `gh auth git-credential` only, never from a helper the machine configured. @@ -177,4 +197,8 @@ everything project-specific comes from the config; `STATE` names its repository; exit 3 (was 2) means "may be off and silent", so that 2 can mean "refused"; the step that adopted an older queue's state file is gone; and the queue's own advice in a comment keeps its backticks and `` placeholder -(`lens.no_lens_text(keep_markdown=True)`), which the source dropped. +(`lens.no_lens_text(keep_markdown=True)`), which the source dropped. For a +required check that never reported, the comment says to push or merge the +base; the source said to re-run, which cannot add a check the head's workflow +files do not have. With two red checks the comment names the first by name +(the source named whichever the check list gave first). diff --git a/fork-ci/actions/pr-queue/pr_queue.py b/fork-ci/actions/pr-queue/pr_queue.py index b7731e2034db0..b9f29311c0637 100755 --- a/fork-ci/actions/pr-queue/pr_queue.py +++ b/fork-ci/actions/pr-queue/pr_queue.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """A merge queue for a repository whose branch ruleset requires pull requests to be up to date with their base (README.md). Each run brings exactly ONE waiting PR up to date: the oldest open, non-draft PR with auto-merge on that is -BEHIND its base. Its CI runs, it merges itself, and the run after the next push to the base moves the queue on. An -armed PR that is already up to date holds the queue while its required checks run. +BEHIND its base and has no failing required check. Its CI runs, it merges itself, and the run after the next push to +the base moves the queue on. An armed PR that is already up to date holds the queue while its required checks run. One +whose required check ended without passing (cancelled) or never reported cannot merge and nothing re-runs that check: +it is told once per head and passed over, as is a red one. Auto-merge only ever merges a head someone checked. Whether GitHub checks --match-head-commit again after the head moved is not documented, and "armed" does not say at which head (a plain push keeps auto-merge on), so the queue relies @@ -38,7 +40,9 @@ SHA = re.compile(r'[0-9a-f]{40}') REPO_RE = re.compile(r'[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+') GONE_RE = re.compile(r"not our ref|unadvertised object|couldn't find remote ref|no such remote ref", re.I) +RED_STATES = ('FAILURE', 'ERROR', 'TIMED_OUT') WAITS_FOR = ('BLOCKED', 'UNSTABLE', 'CLEAN', 'HAS_HOOKS') # up-to-date merge states whose checks the queue waits for +NEVER = 'never reported' UPDATES = 3 # holds() follows at most this many queue updates in a row GIT_MIN = (2, 38) # git merge-tree --write-tree REFUSED, SILENT = 2, 3 @@ -264,6 +268,106 @@ def same_change(self, base, n, head, read): got = self.fetch(base, n, head, read) return holds(self.git, head, read) if got else got + # --- which armed PRs wait, and why ------------------------------------------------------------------------------ + def red_checks(self, pr): + """{name: rollup item of its newest run} of each required check whose newest run really failed: the rollup + lists every run on the head, and an older red must not outvote a newer run (lens.latest_checks). CANCELLED + alone is not a failure (a newer push or update cancels the old run), but a red that only a cancelled run + followed still stands: nothing ever passed.""" + rollup = pr.get('statusCheckRollup') or [] + name_of = lambda c: c.get('name') or c.get('context') + state_of = lambda c: (c.get('conclusion') or c.get('state') or '').upper() + out = {} + for c in lens.latest_checks(rollup): + name = name_of(c) + if name not in self.cfg.required_checks: + continue + if state_of(c) in RED_STATES or (state_of(c) == 'CANCELLED' and + any(name_of(o) == name and state_of(o) in RED_STATES for o in rollup)): + if (c.get('startedAt') or '') >= ((out.get(name) or {}).get('startedAt') or ''): + out[name] = c + return out + + def failing(self, pr): + return next(iter(sorted(self.red_checks(pr))), None) + + def required_now(self, rollup): + """lens.check_state of each required check's newest run, by name: the ruleset takes the newest run of a name, + whichever workflow ran it.""" + newest = {} + for c in lens.latest_checks(rollup): + name, when = c.get('name') or c.get('context'), c.get('startedAt') or '9999' + if name in self.cfg.required_checks and (name not in newest or when >= newest[name][0]): + newest[name] = (when, lens.check_state(c)) + return {name: state for name, (_, state) in newest.items()} + + def workflow_runs(self, head): + """The workflow runs on commit `head` (status, workflowName), or None when GitHub could not say (printed).""" + if not SHA.fullmatch(head or ''): + return None + r = self.gh('run', 'list', '--repo', self.repo, '--commit', head, '--limit', '50', '--json', + 'status,workflowName') + if r.returncode != 0: + print(f'gh run list --commit {head[:12]} failed: {r.stderr.strip()[:200]}') + return None + try: + return json.loads(r.stdout) + except ValueError: + print(f'gh run list --commit {head[:12]} gave no JSON') + return None + + def stuck(self, p): + """An up-to-date armed PR that cannot merge and that nothing moves on any more: (check, what) for a required + check whose newest run ended without passing and without a red (CANCELLED, STARTUP_FAILURE, STALE...), or that + never reported although no workflow runs on the head any more. None: it may still merge (green or running).""" + now = self.required_now(p.get('statusCheckRollup') or []) + ended = sorted((name, state) for name, state in now.items() if state not in ('ok', 'running')) + if ended: + return ended[0][0], f'ended {ended[0][1]}' + missing = sorted(self.cfg.required_checks - set(now)) + if 'running' in now.values() or not missing: + return None + if not (p.get('statusCheckRollup') or []): + print(f'#{p["number"]}: no check has started on its head yet') + return None + head = (p.get('headRefOid') or '').lower() + runs = self.workflow_runs(head) + if runs is None: # unknown is not "nothing runs": wait, and say it + print(f'#{p["number"]}: required check {", ".join(missing)} has not reported, and which workflows still ' + f'run on {head[:12] or "its head"} is unknown: waiting') + return None + if any((x.get('status') or '').lower() != 'completed' for x in runs): + return None + return missing[0], NEVER + + def stuck_fix(self, what, base): + if what == NEVER: # a re-run repeats the run's own workflow file, which has no job for this check + return (f'A re-run cannot add it: a re-run repeats the jobs of the workflow files this head was built ' + f'with, and none of them reported it. Push again (`{self.cfg.push_command}`) or merge {base} into ' + 'it, so that the current workflows run') + return 'Nothing re-runs it by itself: re-run the CI run (Actions, "Re-run failed jobs") or push' + + def tell_red(self, n, check, what='is red', fix='Fix the cause and push'): + """An armed PR the queue skips says why on the PR, once per head, check and reason. No answer from GitHub: the + next run tries again. Told means a comment of the queue's own login (viewerDidAuthor) that starts with the mark: + anyone can post a copy of it on a public repository.""" + pr = self.view(n) + if not pr: + print(f'#{n}: could not read it to say why it waits') + return + mark = f'required check {check} {what} on {(pr.get("headRefOid") or "?")[:12]}' + told = f'pr_queue: {mark},' + if any(c.get('viewerDidAuthor') is True and (c.get('body') or '').startswith(told) + for c in pr.get('comments') or []): + return + also = '.' + if check == self.cfg.head_check and what == 'is red': + also = f', or if a new lens read or override covers this head: `{self.cfg.ready(n)}`.' + r = self.gh('pr', 'comment', str(n), '--repo', self.repo, '--body', + f'{told} so this armed PR waits and the queue passes over it. {fix}' + also) + if r.returncode != 0: + print(f'#{n}: could not comment: {r.stderr.strip()[:300]}') + # --- steps 2-4 -------------------------------------------------------------------------------------------------- def view(self, n): r = self.gh('pr', 'view', str(n), '--repo', self.repo, '--json', VIEW) @@ -517,10 +621,23 @@ def run(self, dry): n = p['number'] if p.get('isDraft') or not p.get('autoMergeRequest'): continue + bad = self.failing(p) + if bad: + print(f'skip #{n}: required check {bad} failed (its author fixes it)') + if not dry: + self.tell_red(n, bad) + continue if p.get('mergeStateStatus') != 'BEHIND': if p.get('mergeStateStatus') not in WAITS_FOR: print(f'skip #{n}: merge state {p.get("mergeStateStatus")}, not one the queue updates or waits for') continue + why = self.stuck(p) + if why: + print(f'skip #{n}: up to date, but required check {why[0]} {why[1]} and nothing re-runs it: it ' + 'cannot merge, so it does not hold the queue') + if not dry: + self.tell_red(n, why[0], why[1], self.stuck_fix(why[1], p.get('baseRefName') or 'its base')) + continue print(f'#{n} is up to date and waiting for its checks: queue is moving') return 0 print(f'update #{n} {(p.get("title") or "")[:70]}') diff --git a/fork-ci/actions/pr-queue/pr_queue.test.py b/fork-ci/actions/pr-queue/pr_queue.test.py index 91903cf6b71be..2977c5e870c0b 100644 --- a/fork-ci/actions/pr-queue/pr_queue.test.py +++ b/fork-ci/actions/pr-queue/pr_queue.test.py @@ -949,6 +949,251 @@ def test_a_cache_with_another_config_or_open_to_others_is_rebuilt(self): self.assertEqual(os.stat(self.q.cache).st_mode & 0o777, 0o700) +def own(comment): + """A PR comment for a fake `gh pr view`: a plain body is one the queue's own login posted.""" + return comment if isinstance(comment, dict) else {'body': comment, 'viewerDidAuthor': True} + + +class RedChecks(unittest.TestCase): + """Only the newest run of a check counts, and an armed PR the queue skips says why on the PR.""" + H = 'd4' * 20 + + def setUp(self): + d = tempfile.mkdtemp(prefix='pr-queue-red-') + self.addCleanup(shutil.rmtree, d, True) + self.q = make_queue(d) + + @staticmethod + def rollup(*runs, name='review-check'): + return [{'__typename': 'CheckRun', 'workflowName': 'ci', 'name': name, 'startedAt': s, 'conclusion': c} + for s, c in runs] + + def failing(self, *runs, name='review-check'): + return self.q.failing({'statusCheckRollup': self.rollup(*runs, name=name)}) + + def test_an_older_red_under_a_newer_green_is_not_failing(self): + self.assertIsNone(self.failing(('2026-11-09T01:00:00Z', 'FAILURE'), ('2026-11-09T02:00:00Z', 'SUCCESS'))) + + def test_a_newer_red_over_an_older_green_is_failing(self): + self.assertEqual(self.failing(('2026-11-09T02:00:00Z', 'FAILURE'), ('2026-11-09T01:00:00Z', 'SUCCESS')), + 'review-check') + + def test_a_newest_timed_out_run_is_failing_and_a_cancelled_one_is_not(self): + self.assertEqual(self.failing(('2026-11-09T02:00:00Z', 'TIMED_OUT')), 'review-check') + self.assertIsNone(self.failing(('2026-11-09T02:00:00Z', 'CANCELLED'))) + + def test_a_red_that_only_a_cancelled_run_followed_still_stands(self): + self.assertEqual(self.failing(('2026-11-09T01:00:00Z', 'FAILURE'), ('2026-11-09T02:00:00Z', 'CANCELLED')), + 'review-check') + + def test_a_green_that_a_cancelled_run_followed_is_not_red(self): + self.assertIsNone(self.failing(('2026-11-09T01:00:00Z', 'SUCCESS'), ('2026-11-09T02:00:00Z', 'CANCELLED'))) + + def test_a_check_that_is_not_required_is_ignored(self): + self.assertIsNone(self.failing(('2026-11-09T02:00:00Z', 'FAILURE'), name='lint')) + + def test_two_red_checks_name_the_first_by_name(self): + rollup = self.rollup(('2026-11-09T02:00:00Z', 'FAILURE'), name='test') + \ + self.rollup(('2026-11-09T02:00:00Z', 'FAILURE'), name='build') + self.assertEqual(self.q.failing({'statusCheckRollup': rollup}), 'build') + + def sweep(self, state='BEHIND', runs=(('2026-11-09T02:00:00Z', 'FAILURE'),), comments=(), head=None, dry=False, + fail=(), name='review-check'): + self.posted, self.updated = [], [] + listed = [{'number': 7, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, 'mergeStateStatus': state, + 'createdAt': '2026-11-07T00:00:00Z', 'statusCheckRollup': self.rollup(*runs, name=name)}] + + def gh(*a): + if a[:2] == ('pr', 'list'): + return ok(json.dumps(listed)) + if a[:2] == ('pr', 'view'): + if 'view' in fail: + return failed('boom') + return ok(json.dumps({'headRefOid': head or self.H, 'comments': [own(c) for c in comments]})) + if a[:2] == ('pr', 'comment'): + self.posted.append(a[-1]) + return failed('boom') if 'comment' in fail else ok() + raise AssertionError(a) + self.q.gh = gh + self.q.update = lambda n, d: self.updated.append(n) or 0 + return run_quiet(self.q.run, dry)[0] + + def test_a_red_armed_pr_is_told_whether_or_not_it_is_behind(self): + for state in ('BEHIND', 'BLOCKED', 'UNSTABLE'): + with self.subTest(state=state): + self.sweep(state) + self.assertEqual((len(self.posted), self.updated), (1, [])) + self.assertIn(f'required check review-check is red on {self.H[:12]}', self.posted[0]) + + def test_a_red_then_cancelled_armed_pr_is_told_and_does_not_hold_the_queue(self): + runs = (('2026-11-09T01:00:00Z', 'FAILURE'), ('2026-11-09T02:00:00Z', 'CANCELLED')) + for state in ('BLOCKED', 'BEHIND'): + with self.subTest(state=state): + self.sweep(state, runs=runs) + self.assertEqual((len(self.posted), self.updated), (1, [])) + + def test_the_message_promises_no_re_run(self): # nothing in the queue re-runs a red check + for name in ('review-check', 'build'): + with self.subTest(name=name): + self.sweep(name=name) + self.assertNotIn('re-run', self.posted[0]) + + def test_only_the_head_check_is_pointed_at_the_ready_command(self): # the lenses are about the head check alone + self.sweep() + self.assertIn('`tool ready 7`', self.posted[0]) + self.sweep(name='build') + self.assertNotIn('tool ready', self.posted[0]) + + def test_it_is_told_once_per_head(self): + self.sweep() + first = self.posted[0] + self.sweep(comments=[first]) + self.assertEqual(self.posted, []) + self.sweep(comments=[first], head='e5' * 20) + self.assertEqual(len(self.posted), 1, 'a new head that is red is a new message') + + def test_only_the_queues_own_comment_counts_as_told(self): # anyone can post a copy of the mark + self.sweep() + first = self.posted[0] + for copy in ({'body': first, 'viewerDidAuthor': False}, {'body': first}, + {'body': '> ' + first, 'viewerDidAuthor': True}): + with self.subTest(copy=copy): + self.sweep(comments=[copy]) + self.assertEqual(self.posted, [first], 'a copy of the mark silenced the queue') + + def test_a_dry_run_posts_nothing(self): + self.sweep(dry=True) + self.assertEqual(self.posted, []) + + def test_a_failed_comment_or_read_does_not_stop_the_queue(self): + for fail in ('comment', 'view'): + with self.subTest(fail=fail): + self.assertEqual(self.sweep(fail=(fail,)), 0) + + def test_an_older_red_no_longer_skips_the_pr(self): + self.sweep(runs=(('2026-11-09T01:00:00Z', 'FAILURE'), ('2026-11-09T02:00:00Z', 'SUCCESS'))) + self.assertEqual((self.posted, self.updated), ([], [7])) + + def test_the_message_can_never_count_as_a_lens(self): + self.sweep() + post = {'body': self.posted[0], 'author': {'login': 'owner'}} + self.assertIsNone(REVIEW.lens_problem(post)) + self.assertEqual(REVIEW.reviewed_head({'comments': [post], 'reviews': []}), (False, None, 'no lens comments')) + + +class StuckUpToDate(unittest.TestCase): + """An armed, up-to-date PR whose required check ended without passing (cancelled) or never reported cannot merge, + and nothing re-runs that check. It is told once per head and passed over; it does not hold the queue.""" + H1, H2 = 'e1' * 20, 'f2' * 20 + + def setUp(self): + d = tempfile.mkdtemp(prefix='pr-queue-stuck-') + self.addCleanup(shutil.rmtree, d, True) + self.q = make_queue(d) + + @staticmethod + def checks(**states): # every required check green, except the ones named (None: the check never reported) + out = [] + for name in sorted(CFG.required_checks): + state = states.get(name.replace('-', '_'), 'SUCCESS') + if state is None: + continue + run = {'__typename': 'CheckRun', 'workflowName': 'ci', 'name': name, 'startedAt': '2026-11-11T01:00:00Z', + 'status': 'COMPLETED', 'conclusion': state} + if state == 'IN_PROGRESS': + run.update(status='IN_PROGRESS', conclusion='') + out.append(run) + return out + + def sweep(self, first, comments=(), runs='[]', runs_fail=False, dry=False): + """#1 (oldest): up to date (BLOCKED) with rollup `first`; #2: BEHIND and green.""" + self.posted, self.updated, self.asked = [], [], [] + listed = [{'number': 1, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, 'mergeStateStatus': 'BLOCKED', + 'createdAt': '2026-11-07T00:00:01Z', 'statusCheckRollup': first, 'headRefOid': self.H1, + 'baseRefName': 'main'}, + {'number': 2, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, 'mergeStateStatus': 'BEHIND', + 'createdAt': '2026-11-07T00:00:02Z', 'statusCheckRollup': self.checks(), 'headRefOid': self.H2, + 'baseRefName': 'main'}] + + def gh(*a): + if a[:2] == ('pr', 'list'): + return ok(json.dumps(listed)) + if a[:2] == ('pr', 'view'): + return ok(json.dumps({'headRefOid': self.H1, 'comments': [own(c) for c in comments]})) + if a[:2] == ('pr', 'comment'): + self.posted.append(a[-1]) + return ok() + if a[:2] == ('run', 'list'): + self.asked.append(a[a.index('--commit') + 1]) + return failed('boom') if runs_fail else ok(runs) + raise AssertionError(a) + self.q.gh = gh + self.q.update = lambda n, d: self.updated.append(n) or 0 + return run_quiet(self.q.run, dry) + + def test_a_cancelled_required_check_is_told_and_passed_over(self): + code, out = self.sweep(self.checks(build='CANCELLED')) + self.assertEqual((code, self.updated, len(self.posted)), (0, [2], 1), out) + self.assertIn(f'required check build ended CANCELLED on {self.H1[:12]}', self.posted[0]) + self.assertIn('Re-run failed jobs', self.posted[0]) + self.assertNotIn('tool ready', self.posted[0]) + + def test_a_rerun_tells_it_once_per_head_and_still_moves_on(self): + self.sweep(self.checks(review_check='CANCELLED')) + first = self.posted[0] + self.sweep(self.checks(review_check='CANCELLED'), comments=[first]) + self.assertEqual((self.posted, self.updated), ([], [2])) + self.assertNotIn('tool ready', first, 'a cancelled head check needs a re-run, not new lenses') + + def test_other_ended_states_count_too(self): # check_state's rule: only an unfinished check is running + for state in ('STARTUP_FAILURE', 'STALE', 'ACTION_REQUIRED'): + with self.subTest(state=state): + self.sweep(self.checks(test=state)) + self.assertEqual(self.updated, [2]) + self.assertIn(f'ended {state}', self.posted[0]) + self.assertIn('Re-run failed jobs', self.posted[0]) + + def test_a_re_run_of_the_cancelled_check_holds_the_queue_again(self): # the newest run counts: it is running + rerun = self.checks(build='CANCELLED') + [{'__typename': 'CheckRun', 'workflowName': 'ci', 'name': 'build', + 'status': 'QUEUED', 'conclusion': ''}] + code, out = self.sweep(rerun) + self.assertEqual((code, self.updated, self.posted), (0, [], []), out) + self.assertIn('#1 is up to date and waiting', out) + + def test_green_or_running_checks_still_hold_the_queue(self): + for first in (self.checks(), self.checks(review_check='IN_PROGRESS'), []): + with self.subTest(first=len(first)): + code, out = self.sweep(first) + self.assertEqual((code, self.updated, self.posted, self.asked), (0, [], [], []), out) + + def test_a_required_check_that_never_reports_is_told_once_nothing_runs(self): + code, out = self.sweep(self.checks(test=None), runs='[{"status": "completed", "workflowName": "ci"}]') + self.assertEqual((self.asked, self.updated, len(self.posted)), ([self.H1], [2], 1), out) + self.assertIn('required check test never reported', self.posted[0]) + + def test_a_check_that_never_reported_is_not_told_to_re_run(self): # the source told it to re-run + # A re-run repeats the jobs of the workflow files the head was built with: a check added to the workflows + # after that never runs on a re-run. Only a push or a merge of the base runs the current workflows. + self.sweep(self.checks(test=None), runs='[{"status": "completed", "workflowName": "ci"}]') + told = self.posted[0] + self.assertNotIn('Re-run failed jobs', told, 'a re-run cannot add a check its workflow files do not have') + self.assertIn('A re-run cannot add it', told) + self.assertIn('`tool push`', told) + self.assertIn('merge main into it', told) + + def test_a_missing_check_waits_while_a_workflow_runs_or_when_runs_are_unknown(self): + for kw in ({'runs': '[{"status": "in_progress", "workflowName": "ci"}]'}, {'runs_fail': True}, {'runs': 'x'}): + with self.subTest(**kw): + code, out = self.sweep(self.checks(test=None), **kw) + self.assertEqual((code, self.updated, self.posted), (0, [], []), out) + self.assertIn('is unknown: waiting', out, 'an unknown answer is said, not treated as "nothing runs"') + + def test_a_dry_run_posts_nothing_and_says_what_it_skips(self): + code, out = self.sweep(self.checks(build='CANCELLED'), dry=True) + self.assertEqual((self.posted, self.updated), ([], [2])) + self.assertIn('skip #1: up to date, but required check build ended CANCELLED', out) + + # --- the source project's lens-review cases for the queue ------------------------------------------------------------ HEAD = 'def wipe(player, island):\n' READ = HEAD + " if is_admin(player):\n log.info('wipe')\n wipe_island(island)\n" # only an admin wipes From fb86cc3ccaa6c633b7a3acc709a236e647c89f4a Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:49:11 +0100 Subject: [PATCH 20/27] fork-ci pr-queue 4/4: update a PR that was red only because its base was, and the reusable workflow (#21) * fork-ci pr-queue 4/4: update a PR that was red only because its base was, and the reusable workflow The queue can now update a behind pull request whose required checks failed only because the base commit its run tested failed them too, and the base is green now (config update_when_base_was_red, off by default). Each required job notes the base it tested with the new tested-base action (the merge commit's first parent, as a job annotation), so a late job or a re-run never trusts a clock. Anything unknown counts as the pull request's own red, as before. Also: action.yml (the queue as a composite action; it refuses the run's GITHUB_TOKEN), the reusable workflow .github/workflows/fork-pr-queue.yml (push, schedule and workflow_dispatch only; runs the queue from its own commit), their tests, and the README's usage sections. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 4/4: say what the queue's runner and head_check must be Security review fixes, README "Use it": - The queue's runner (or runner group) runs no pull_request job and no other repository's job, as a user of its own, and its state and cache folders are that user's alone. - head_check is required (1/4): without it a push to an armed, up-to-date pull request merges unread. - "The base was red": the pull request's own job code could write the tested-base note too. A forged note only gets a red pull request updated or told; the re-arm still needs the change the lenses read. Co-Authored-By: Claude Opus 5.5 * fork-ci pr-queue 4/4: one red of the PR's own beats a base that is still red (lens r3 major) red_from_old_base returned "the base is why" at the first red check that was red on its tested base and still red on the base tip, before looking at the others, so a PR with its own broken check beside it was told "not this PR's fault" and waited. It now looks at every red check: any red of the PR's own (or one nothing proves) makes it the author's; only when every red came from the base does it wait, and the notice names the check that is still red on the base, not the first red one. Two tests, both red on the old code. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/fork-ci-tests.txt | 2 + .../scripts/fork-pr-queue-workflow.test.py | 167 ++++++++++++++ .github/workflows/fork-pr-queue.yml | 99 +++++++++ fork-ci/actions/pr-queue/README.md | 133 +++++++++++- fork-ci/actions/pr-queue/action.yml | 60 +++++ fork-ci/actions/pr-queue/config.py | 3 + fork-ci/actions/pr-queue/pr_queue.py | 111 +++++++++- fork-ci/actions/pr-queue/pr_queue.test.py | 205 +++++++++++++++++- .../actions/pr-queue/tested-base/action.yml | 19 ++ .../pr-queue/tested-base/tested_base.py | 68 ++++++ .../pr-queue/tested-base/tested_base.test.py | 140 ++++++++++++ 11 files changed, 1001 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/fork-pr-queue-workflow.test.py create mode 100644 .github/workflows/fork-pr-queue.yml create mode 100644 fork-ci/actions/pr-queue/action.yml create mode 100644 fork-ci/actions/pr-queue/tested-base/action.yml create mode 100644 fork-ci/actions/pr-queue/tested-base/tested_base.py create mode 100644 fork-ci/actions/pr-queue/tested-base/tested_base.test.py diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 57f9225c5ff11..c38f0a7bf5292 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -9,6 +9,7 @@ .github/scripts/fork-contracts.test.py .github/scripts/fork-fleet.test.py .github/scripts/fork-follow.test.sh +.github/scripts/fork-pr-queue-workflow.test.py .github/scripts/fork-release-workflow.test.py .github/scripts/fork-release.test.sh .github/scripts/fork-schema-check.test.cjs @@ -21,6 +22,7 @@ fork-ci/actions/gradle-cache/gradle_cache.test.py fork-ci/actions/job-changed/job-changed.test.py fork-ci/actions/pr-queue/lens.test.py fork-ci/actions/pr-queue/pr_queue.test.py +fork-ci/actions/pr-queue/tested-base/tested_base.test.py # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/.github/scripts/fork-pr-queue-workflow.test.py b/.github/scripts/fork-pr-queue-workflow.test.py new file mode 100644 index 0000000000000..19b8500230cce --- /dev/null +++ b/.github/scripts/fork-pr-queue-workflow.test.py @@ -0,0 +1,167 @@ +"""Checks .github/workflows/fork-pr-queue.yml and the action it runs (fork-ci/actions/pr-queue/action.yml): the +refusals are run as Actions runs them, the wiring is read from the files. + + python3 .github/scripts/fork-pr-queue-workflow.test.py + +Needs PyYAML and bash. Each check prints ok/FAIL; any FAIL exits 1. Nothing here starts gh or the queue: a fake +python3 stands in for the queue's start. +""" + +import os +import pathlib +import re +import subprocess +import sys +import tempfile + +import yaml + +root = pathlib.Path(__file__).resolve().parents[2] +WORKFLOW = root / ".github/workflows/fork-pr-queue.yml" +text = WORKFLOW.read_text() +wf = yaml.safe_load(text) +failures = [] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name + ("" if ok or not detail else f"\n | {detail}")) + if not ok: + failures.append(name) + + +def bash(script, env, path=None): + """The script as a bash `run:` step runs it (bash --noprofile --norc -eo pipefail).""" + base = {"PATH": (path + os.pathsep if path else "") + os.environ["PATH"]} + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f: + f.write(script) + try: + return subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", f.name], env={**base, **env}, + capture_output=True, text=True) + finally: + os.remove(f.name) + + +# --- triggers, permissions, concurrency ----------------------------------------------------------------------------- +on = wf.get(True, wf.get("on")) # YAML 1.1 reads the key `on` as true +check("it is only a reusable workflow (workflow_call): no event of its own starts it", set(on) == {"workflow_call"}, + str(set(on))) +jobs = wf["jobs"] +job = jobs.get("queue") or {} +check("one job, queue", set(jobs) == {"queue"}, ", ".join(jobs)) +check("no token by default; the job reads contents only (the queue acts with the token secret)", + wf.get("permissions") == {} and job.get("permissions") == {"contents": "read"}) +conc = job.get("concurrency") or {} +check("one queue run per repository at a time, never cancelled half-way", + "github.repository" in str(conc.get("group")) and conc.get("cancel-in-progress") is False, str(conc)) +check("it runs on the caller's runner (a self-hosted one: pr_queue.py refuses a GitHub-hosted one)", + job.get("runs-on") == "${{ fromJSON(inputs.runs-on) }}") + +steps = job.get("steps") or [] +injected = [s.get("name") for s in steps if "${{" in str(s.get("run", ""))] +check("no expression inside a run script (inputs reach it as env only)", not injected, ", ".join(map(str, injected))) + +# --- the first step: refusals and this workflow's own commit -------------------------------------------------------- +first = steps[0] if steps else {} +check("the first step refuses and finds the toolkit, before anything is checked out", + first.get("id") == "toolkit" and "uses" not in first and first.get("shell") == "bash") +SHA = "0123456789abcdef0123456789abcdef01234567" +GOOD_JOB = '{"status": "success", "workflow_repository": "owner/toolkit", "workflow_sha": "%s"}' % SHA + + +def refusal(event="push", config=".github/pr-queue.json", job_context=GOOD_JOB): + with tempfile.NamedTemporaryFile("r", prefix="fork-pr-queue-out-") as out: + r = bash(first.get("run", "exit 0"), {"EVENT": event, "CONFIG": config, "JOB_CONTEXT": job_context, + "GITHUB_OUTPUT": out.name}) + return r, out.read() + + +for event in ("push", "schedule", "workflow_dispatch"): + r, out = refusal(event) + check(f"refusal: a {event} run goes on and names the toolkit's repository and commit", + r.returncode == 0 and out == f"repo=owner/toolkit\nsha={SHA}\n", f"{r.returncode} {r.stdout} {out!r}") +for event in ("pull_request", "pull_request_target", "issue_comment", "workflow_run", "merge_group", ""): + r, out = refusal(event) + check(f"refusal: a {event or 'unnamed'} event fails and writes no output", + r.returncode == 1 and out == "" and "refused on a" in r.stdout, f"{r.returncode} {r.stdout}") +for label, ctx in (("an empty job context", ""), ("no workflow_sha (an old runner)", '{"status": "success"}'), + ("a sha that is a branch name", GOOD_JOB.replace(SHA, "main")), + ("a repository that is no owner/name", GOOD_JOB.replace("owner/toolkit", "owner/toolkit/x")), + ("no JSON", "{")): + r, out = refusal(job_context=ctx) + check(f"refusal: {label} fails: it never guesses which queue to run", + r.returncode == 1 and out == "" and "will not guess" in r.stdout, f"{r.returncode} {r.stdout} {r.stderr}") +for config in ("/etc/pr-queue.json", "../pr-queue.json", "a/../../pr-queue.json", "a/..", ""): + r, out = refusal(config=config) + check(f"refusal: config {config!r} outside the caller's checkout fails", + r.returncode == 1 and out == "" and "is not a path inside" in r.stdout, f"{r.returncode} {r.stdout}") +r, out = refusal(config="ci/pr..queue.json") +check("refusal: a name with two dots in it is no parent folder", r.returncode == 0, r.stdout) + +# --- the checkouts and the action ------------------------------------------------------------------------------------ +by_name = {s.get("name"): s for s in steps} +caller = by_name.get("Check out the caller's config") or {} +toolkit = by_name.get("Check out the queue at this workflow's own commit") or {} +queue = by_name.get("Run the queue") or {} +check("steps: refuse, the caller's config, the toolkit, the queue, in that order", + steps == [first, caller, toolkit, queue], ", ".join(str(s.get("name")) for s in steps)) +pinned = all(re.fullmatch(r"actions/checkout@[0-9a-f]{40}", str(s.get("uses"))) for s in (caller, toolkit)) +check("both checkouts are actions/checkout pinned by commit", pinned) +cw, tw = caller.get("with") or {}, toolkit.get("with") or {} +check("the caller's checkout holds only the config, in its own folder", + cw == {"path": "caller", "sparse-checkout": "${{ inputs.config }}", "sparse-checkout-cone-mode": False}, str(cw)) +check("the toolkit is checked out at this workflow's own repository and commit, not the caller's", + tw.get("repository") == "${{ steps.toolkit.outputs.repo }}" and tw.get("ref") == "${{ steps.toolkit.outputs.sha }}" + and set(tw) == {"repository", "ref", "path", "sparse-checkout"}, str(tw)) +action_dir = str(tw.get("sparse-checkout")) +check("the queue step runs the action from that checkout", + queue.get("uses") == f"./{tw.get('path')}/{action_dir}" and (root / action_dir / "action.yml").is_file(), + f"{queue.get('uses')} vs ./{tw.get('path')}/{action_dir}") +action = yaml.safe_load((root / action_dir / "action.yml").read_text()) +inputs = action.get("inputs") or {} +qw = queue.get("with") or {} +required = {k for k, v in inputs.items() if v.get("required")} +check("the queue step gives only inputs the action has, and every required one", + set(qw) <= set(inputs) and required <= set(qw), f"given {sorted(qw)}, action {sorted(inputs)}") +check("the config is read from the caller's checkout", qw.get("config") == "caller/${{ inputs.config }}") +check("the token secret reaches only the queue step's token input", + text.count("secrets.") == 1 and qw.get("token") == "${{ secrets.token }}") +check("the action's repository defaults to the caller's", inputs.get("repo", {}).get("default") == "${{ github.repository }}") + +# --- the action's script: refusals, and the call it makes ------------------------------------------------------------ +astep = (action.get("runs") or {}).get("steps", [{}])[0] +check("the action is one bash step with no expression in its script", + action.get("runs", {}).get("using") == "composite" and astep.get("shell") == "bash" + and "${{" not in str(astep.get("run"))) +check("the action passes the token to gh as GH_TOKEN, and the run's own token only to compare", + (astep.get("env") or {}).get("GH_TOKEN") == "${{ inputs.token }}" + and (astep.get("env") or {}).get("RUN_TOKEN") == "${{ github.token }}") +fake = tempfile.mkdtemp(prefix="fork-pr-queue-bin-") +with open(os.path.join(fake, "python3"), "w") as f: + f.write('#!/bin/sh\necho "started: $*"\necho "RUN_TOKEN=${RUN_TOKEN-unset} GH_TOKEN=${GH_TOKEN:+set}"\n') +os.chmod(os.path.join(fake, "python3"), 0o755) + + +def action_run(token="t-queue", run_token="t-run", dry="false", lock=""): + return bash(astep.get("run", "exit 0"), {"GH_TOKEN": token, "RUN_TOKEN": run_token, "REPO": "owner/proj", + "CONFIG": "caller/q.json", "STATE_FILE": "/srv/q/plan.json", + "CACHE": "/srv/q/cache.git", "LOCK_FILE": lock, "DRY_RUN": dry, + "GITHUB_ACTION_PATH": "/act"}, path=fake) + + +r = action_run() +check("action: a good call starts the queue with exactly its arguments, GH_TOKEN set and the run's token unset", + r.returncode == 0 and r.stdout == "started: -I /act/pr_queue.py --repo owner/proj --config caller/q.json " + "--state-file /srv/q/plan.json --cache /srv/q/cache.git\nRUN_TOKEN=unset GH_TOKEN=set\n", r.stdout + r.stderr) +r = action_run(dry="true", lock="/srv/q/l") +check("action: dry-run true and a lock file are passed on", + r.returncode == 0 and "--lock-file /srv/q/l --dry-run\n" in r.stdout, r.stdout) +for label, kw in (("the run's GITHUB_TOKEN as the token", {"token": "t-run"}), ("an empty token", {"token": ""}), + ("a dry-run that is not true or false", {"dry": "yes"})): + r = action_run(**kw) + check(f"action: {label} is refused before the queue starts, and no token is printed", + r.returncode == 2 and "started:" not in r.stdout and "t-run" not in r.stdout + r.stderr, r.stdout) + +if failures: + print(f"\n{len(failures)} failed") + sys.exit(1) +print("\nall passed") diff --git a/.github/workflows/fork-pr-queue.yml b/.github/workflows/fork-pr-queue.yml new file mode 100644 index 0000000000000..945701796f9bb --- /dev/null +++ b/.github/workflows/fork-pr-queue.yml @@ -0,0 +1,99 @@ +# Fork only. The merge queue of fork-ci/actions/pr-queue as a reusable workflow (that folder's README.md, "Use it"). +# A caller runs it after each push to its base branch and on a timer. It runs the queue from this workflow's own +# commit (job.workflow_repository and job.workflow_sha), never from the caller's tree, and only on push, schedule or +# workflow_dispatch: the code that merges pull requests must never run for a pull request. +# Tests: .github/scripts/fork-pr-queue-workflow.test.py. +name: Fork PR queue + +on: + workflow_call: + inputs: + runs-on: + description: JSON for runs-on, a self-hosted runner whose disk outlives the job and that runs no pull_request job (README, "Use it"), e.g. '["self-hosted", "queue"]'. + type: string + required: true + config: + description: Path of the queue's JSON config in the caller's repository, from its root. + type: string + required: true + state-file: + description: Absolute path on the runner, outside the workspace, of the plan a stopped run leaves. + type: string + required: true + cache: + description: Absolute path on the runner, outside the workspace, of the queue's own bare git repository. + type: string + required: true + lock-file: + description: Absolute path of the lock that keeps two runs apart (default the state file + .lock). + type: string + default: "" + dry-run: + description: Say what a run would do and write nothing. + type: boolean + default: false + secrets: + token: + description: Updates branches, comments and merges; not GITHUB_TOKEN (its branch updates start no workflow). + required: true + +permissions: {} + +jobs: + queue: + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: 20 + permissions: + contents: read + concurrency: + group: fork-pr-queue-${{ github.repository }} + cancel-in-progress: false # a cancel is a stop half-way: the next run would have to finish it + steps: + - name: Refuse other events; find this workflow's own commit + id: toolkit + shell: bash + env: + EVENT: ${{ github.event_name }} + CONFIG: ${{ inputs.config }} + JOB_CONTEXT: ${{ toJSON(job) }} + run: | + case "$EVENT" in + push|schedule|workflow_dispatch) ;; + *) echo "::error::pr-queue: refused on a ${EVENT:-unnamed} event: it runs only on push, schedule or workflow_dispatch" + exit 1 ;; + esac + if [[ -z "$CONFIG" || "$CONFIG" == /* || "/$CONFIG/" == */../* ]]; then + echo "::error::pr-queue: config '$CONFIG' is not a path inside the caller's repository" + exit 1 + fi + read -r repo sha < <(python3 -I -c 'import json, os + job = json.loads(os.environ["JOB_CONTEXT"] or "{}") + print(job.get("workflow_repository") or "-", job.get("workflow_sha") or "-")') || true + if ! [[ "$repo" =~ ^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+$ && "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::pr-queue: the runner does not say which commit this reusable workflow is (job.workflow_repository, job.workflow_sha; runner 2.334.0 or newer), so it will not guess which queue to run" + exit 1 + fi + echo "pr-queue from $repo at $sha" + { echo "repo=$repo"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT" + - name: Check out the caller's config + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + path: caller + sparse-checkout: ${{ inputs.config }} + sparse-checkout-cone-mode: false + - name: Check out the queue at this workflow's own commit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: ${{ steps.toolkit.outputs.repo }} + ref: ${{ steps.toolkit.outputs.sha }} + path: toolkit + sparse-checkout: fork-ci/actions/pr-queue + - name: Run the queue + uses: ./toolkit/fork-ci/actions/pr-queue + with: + config: caller/${{ inputs.config }} + state-file: ${{ inputs.state-file }} + cache: ${{ inputs.cache }} + lock-file: ${{ inputs.lock-file }} + dry-run: ${{ inputs.dry-run }} + token: ${{ secrets.token }} diff --git a/fork-ci/actions/pr-queue/README.md b/fork-ci/actions/pr-queue/README.md index 9f6c4cadf9f1a..64aeaf05dd97c 100644 --- a/fork-ci/actions/pr-queue/README.md +++ b/fork-ci/actions/pr-queue/README.md @@ -10,11 +10,10 @@ This folder is built in four stacked pull requests: 1. the review rules and the config (`lens.py`, `config.py`); 2. the queue itself (`pr_queue.py`, section "The queue"); 3. armed pull requests that cannot merge (a red required check, or one that - ended without passing or never reported) are told once and passed over: - this part; + ended without passing or never reported) are told once and passed over; 4. the "red only because the base was red" rule, the reusable workflow - `.github/workflows/fork-pr-queue.yml`, `action.yml` and the usage sections - of this file. + `.github/workflows/fork-pr-queue.yml`, `action.yml`, `tested-base/` and the + usage sections of this file: this part. ## Config @@ -52,6 +51,7 @@ an error: the queue then exits non-zero and touches no pull request. | `wait_seconds` | no | How long to wait for GitHub to show the updated head (default 60). | | `poll_seconds` | no | How often to look meanwhile (default 5). | | `list_limit` | no | How many open pull requests to read (default 50). | +| `update_when_base_was_red` | no | `true` updates a behind pull request whose required checks failed only because the base commit they tested failed them too (section "The base was red"). Set it only when the job of every required check runs the `tested-base` action. Default `false`. | ## Lens comments @@ -174,6 +174,122 @@ exists. `--lock-file` (default: `STATE.lock`) keeps two runs from overlapping. `--dry-run` takes no lock and writes nothing: no `STATE`, no cache, no comment. +## The base was red + +A `pull_request` run tests GitHub's merge of the head into the base as the +base was when the run started. When that base commit failed the same check, +the red says nothing about the pull request, and nothing runs the check again +on a newer base. With `update_when_base_was_red`, a behind pull request with +red required checks is therefore updated when, for every red required check: + +- the job of the check's newest run names the base commit it tested (the + `tested-base` action's note); +- that base commit has a real failure of the same check (`FAILURE` or + `ERROR`: a cancel, a time-out, a skip or no result says nothing about the + base's code); and +- the base branch's tip has the check green now. + +When the base tip is not green yet, the pull request is told once, per head +and check, that it failed as on its base and that the queue updates it by +itself once the base is green. It does not hold the queue meanwhile. When +anything is unknown (no note among the job's first 100 annotations, a failed read, +more than 100 checks on a commit), the red counts as the pull request's own, as without +the rule. There is no state and no loop: after the update the new runs test a +green base, so a red then is the pull request's own. A re-run of the old merge +names the old red base again and is updated, which it needs. + +The base branch's pushes must run the required checks under the same names, +or every base commit has "no result" and no red is ever the base's. The rule +reads the tested base from the job, never from a clock: a job that starts late +or a re-run of an old merge still tests the base its merge commit holds. + +The note is an annotation on the job, titled `pr_queue tested base`, whose +message is the merge commit's first parent. `tested-base/` writes it only on a +`pull_request` run whose checkout is exactly the run's merge commit (`HEAD` is +`GITHUB_SHA`, with two parents, the second the event's pull request head). +Otherwise it writes a warning and no note. It never fails the job, and a +depth-1 checkout is enough. The pull request's own code runs in that job and +could write such a note too. A forged note can only get a red pull request +updated, or told that its base is the reason; it never re-arms one, because +the re-arm still needs exactly the change the lenses read. + +## Use it + +The queue needs: + +- a self-hosted runner whose disk outlives the job, with git 2.38+, `gh` and + `python3`: the plan in `--state-file` and the cache must be there for the + next run. That runner (or runner group) runs no `pull_request` job and no + other repository's job, as a user of its own, and the state and cache + folders are that user's alone: the token is in git's environment there, and + the plan decides what is re-armed ("The queue"); +- a required `head_check` that fails a head no lens read. The queue only + touches pull requests that are behind; without that check, a push to an + armed pull request that is already up to date merges unread; +- a run after each push to the base branch and on a timer, so a pull request + armed while the base is quiet is still picked up; +- a token that can update branches, comment and merge (a fine-grained personal + access token or a GitHub App token with contents, pull requests and issues + write). Not the run's `GITHUB_TOKEN`: a branch update pushed with it starts + no workflow, so the updated pull request would never get its checks. The + action refuses it. + +### The reusable workflow + +```yaml +name: PR queue +on: + push: + branches: [main] + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: +permissions: {} +jobs: + queue: + permissions: + contents: read + uses: OWNER/REPO/.github/workflows/fork-pr-queue.yml@<40-hex sha> + with: + runs-on: '["self-hosted", "queue"]' + config: .github/pr-queue.json + state-file: /srv/pr-queue/plan.json + cache: /srv/pr-queue/cache.git + secrets: + token: ${{ secrets.PR_QUEUE_TOKEN }} +``` + +It runs only on `push`, `schedule` and `workflow_dispatch`, so the code that +merges pull requests never runs for a pull request. It checks out only the +config file from the caller, and the queue from the workflow's own repository +and commit (`job.workflow_repository`, `job.workflow_sha`, runner 2.334.0 or +newer): the queue that runs is the one the caller pinned. One run per +repository at a time, never cancelled half-way. Inputs: `runs-on`, `config`, +`state-file`, `cache`, `lock-file` and `dry-run`, as in "The queue". + +### The actions + +A project that writes its own job can use the action directly, after a +checkout of its config: + +```yaml +- uses: OWNER/REPO/fork-ci/actions/pr-queue@<40-hex sha> + with: + config: .github/pr-queue.json + state-file: /srv/pr-queue/plan.json + cache: /srv/pr-queue/cache.git + token: ${{ secrets.PR_QUEUE_TOKEN }} +``` + +With `update_when_base_was_red`, the job of every required check runs +`tested-base` right after its checkout of the merge commit (`path` is the +checkout's path, default `.`): + +```yaml +- uses: actions/checkout@ +- uses: OWNER/REPO/fork-ci/actions/pr-queue/tested-base@<40-hex sha> +``` + ## Where the rules come from `lens.py` is a port of a production queue's review rules, behaviour for @@ -202,3 +318,12 @@ required check that never reported, the comment says to push or merge the base; the source said to re-run, which cannot add a check the head's workflow files do not have. With two red checks the comment names the first by name (the source named whichever the check list gave first). + +The "base was red" rule is a port of a change proposed to the source queue, +with its tests. What the port changed: the rule is off unless the config turns +it on; the note is written by a general action (`tested-base/`) that also +checks the checkout is the run's merge commit, where the source had a step in +the project's own CI; a job's annotations are read 100 to a page (the source +read GitHub's default page of 30) and a commit with more than 100 checks is +unknown (the source read the first 100 as all); and red checks are looked at +in name order, so a run names the same reason each time. diff --git a/fork-ci/actions/pr-queue/action.yml b/fork-ci/actions/pr-queue/action.yml new file mode 100644 index 0000000000000..78375a246db75 --- /dev/null +++ b/fork-ci/actions/pr-queue/action.yml @@ -0,0 +1,60 @@ +name: pr-queue +description: >- + A merge queue for a repository whose ruleset requires pull requests to be up to date: each run brings one waiting + pull request up to date and turns auto-merge back on only for the head the review read (README.md). Needs a + self-hosted runner whose disk outlives the job, with git 2.38+, gh and python3. +inputs: + repo: + description: The repository, owner/name. + required: false + default: ${{ github.repository }} + config: + description: The queue's JSON config (README.md, "Config"). + required: true + state-file: + description: Absolute path, outside the job's workspace, of the plan a stopped run leaves for the next one. + required: true + cache: + description: Absolute path, outside the job's workspace, of the queue's own bare git repository. + required: true + lock-file: + description: Absolute path of the lock that keeps two runs apart (default the state file + .lock). + required: false + default: "" + dry-run: + description: true says what a run would do and writes nothing. + required: false + default: "false" + token: + description: >- + A token that can update branches, comment and merge pull requests. Not the run's GITHUB_TOKEN: a branch update + pushed with it starts no workflow, so the updated pull request would never get its checks. + required: true +runs: + using: composite + steps: + - name: Run the queue + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + RUN_TOKEN: ${{ github.token }} + REPO: ${{ inputs.repo }} + CONFIG: ${{ inputs.config }} + STATE_FILE: ${{ inputs.state-file }} + CACHE: ${{ inputs.cache }} + LOCK_FILE: ${{ inputs.lock-file }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + if [[ -z "$GH_TOKEN" || "$GH_TOKEN" == "$RUN_TOKEN" ]]; then + echo "::error::pr-queue: token is empty or the run's GITHUB_TOKEN, whose branch updates start no workflow" + exit 2 + fi + unset RUN_TOKEN + args=(--repo "$REPO" --config "$CONFIG" --state-file "$STATE_FILE" --cache "$CACHE") + if [[ -n "$LOCK_FILE" ]]; then args+=(--lock-file "$LOCK_FILE"); fi + case "$DRY_RUN" in + true) args+=(--dry-run) ;; + false) ;; + *) echo "::error::pr-queue: dry-run must be true or false, not '$DRY_RUN'"; exit 2 ;; + esac + exec python3 -I "$GITHUB_ACTION_PATH/pr_queue.py" "${args[@]}" diff --git a/fork-ci/actions/pr-queue/config.py b/fork-ci/actions/pr-queue/config.py index c4ef42a6a8add..7338d853958e9 100644 --- a/fork-ci/actions/pr-queue/config.py +++ b/fork-ci/actions/pr-queue/config.py @@ -36,6 +36,7 @@ class Config: wait_seconds: int = 60 poll_seconds: int = 5 list_limit: int = 50 + update_when_base_was_red: bool = False def ready(self, n): """The command that re-arms PR n after a lens read, as the project writes it.""" @@ -84,6 +85,8 @@ def _int(lo, hi): 'wait_seconds': (False, _int(5, 600), 'whole seconds, 5-600'), 'poll_seconds': (False, _int(1, 60), 'whole seconds, 1-60'), 'list_limit': (False, _int(1, 100), 'how many open PRs the queue reads, 1-100'), + 'update_when_base_was_red': (False, lambda v: isinstance(v, bool), + 'true only when every required check\'s job runs the tested-base action, else false'), } diff --git a/fork-ci/actions/pr-queue/pr_queue.py b/fork-ci/actions/pr-queue/pr_queue.py index b9f29311c0637..5e1f6c7a01fbb 100755 --- a/fork-ci/actions/pr-queue/pr_queue.py +++ b/fork-ci/actions/pr-queue/pr_queue.py @@ -43,6 +43,9 @@ RED_STATES = ('FAILURE', 'ERROR', 'TIMED_OUT') WAITS_FOR = ('BLOCKED', 'UNSTABLE', 'CLEAN', 'HAS_HOOKS') # up-to-date merge states whose checks the queue waits for NEVER = 'never reported' +TESTED_BASE = 'pr_queue tested base' # the annotation title the tested-base action writes (tested_base.py) +BASE_RED = ('FAILURE', 'ERROR') # "the base was red": a real failure. CANCELLED, STALE, STARTUP_FAILURE, TIMED_OUT, +# SKIPPED, not reported or running say nothing about the base's code (a busy runner times out or cancels base runs) UPDATES = 3 # holds() follows at most this many queue updates in a row GIT_MIN = (2, 38) # git merge-tree --write-tree REFUSED, SILENT = 2, 3 @@ -368,6 +371,95 @@ def tell_red(self, n, check, what='is red', fix='Fix the cause and push'): if r.returncode != 0: print(f'#{n}: could not comment: {r.stderr.strip()[:300]}') + # --- red only because the base it was tested on was red (config update_when_base_was_red) ----------------------- + def tested_base(self, c): + """The base commit the run of rollup item `c` merged the PR with: its job's TESTED_BASE annotation (the + tested-base action writes the merge commit's first parent), so a re-run of an old merge and a job that started + late both name the base they really tested, never a clock. None when unknown (printed).""" + name = c.get('name') or c.get('context') + m = re.search(r'/actions/runs/\d+/job/(\d+)', c.get('detailsUrl') or '') + if not m: + print(f'{name}: no Actions job to read its tested base from') + return None + r = self.gh('api', f'repos/{self.repo}/check-runs/{m[1]}/annotations?per_page=100') + try: + notes = json.loads(r.stdout) if r.returncode == 0 else None + except ValueError: + notes = None + if not isinstance(notes, list): + print(f'{name}: reading the annotations of job {m[1]} failed: {r.stderr.strip()[:200] or "no JSON list"}') + return None + found = {(a.get('message') or '').strip() for a in notes + if isinstance(a, dict) and a.get('title') == TESTED_BASE} + found = {x for x in found if SHA.fullmatch(x)} + if len(found) != 1: + more = ' (it has 100 or more annotations: the note may be on a later page)' if len(notes) >= 100 else '' + print(f'{name}: job {m[1]} names {len(found)} tested bases{more}') + return None + return found.pop() + + def commit_checks(self, expr, cache): + """(oid, required_now of its checks) of commit `expr` (a sha or refs/heads/), read once per run (`cache`); + None when GitHub could not say (printed).""" + if expr in cache: + return cache[expr] + owner, name = self.repo.split('/') + q = ('query($owner:String!,$name:String!,$expr:String!){repository(owner:$owner,name:$name){object(expression:' + '$expr){... on Commit{oid statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{__typename ' + '... on CheckRun{name status conclusion startedAt checkSuite{workflowRun{workflow{name}}}} ' + '... on StatusContext{context state}}}}}}}}') + r = self.gh('api', 'graphql', '-f', f'query={q}', '-f', f'owner={owner}', '-f', f'name={name}', + '-f', f'expr={expr}') + out = None + try: + commit = json.loads(r.stdout)['data']['repository']['object'] if r.returncode == 0 else None + contexts = (commit.get('statusCheckRollup') or {}).get('contexts') or {} if commit else {} + if r.returncode != 0: + print(f'reading the checks of {expr[:40]} failed: {r.stderr.strip()[:200]}') + elif not commit or not SHA.fullmatch(commit.get('oid') or ''): + print(f'the checks of {expr[:40]}: GitHub gave no commit') + elif (contexts.get('pageInfo') or {}).get('hasNextPage'): + print(f'the checks of {expr[:40]}: more than 100, not all read') + else: + rollup = [{**x, 'workflowName': (((x.get('checkSuite') or {}).get('workflowRun') or {}).get('workflow') + or {}).get('name') or ''} for x in contexts.get('nodes') or []] + out = (commit['oid'], self.required_now(rollup)) + except (ValueError, KeyError, TypeError, AttributeError): + print(f'the checks of {expr[:40]}: GitHub gave no commit') + cache[expr] = out + return out + + def red_from_old_base(self, p, cache): + """A BEHIND PR whose required checks failed only because the base they were tested on was red. (None, None) + when, for EVERY red required check C, the base commit its run really merged with (tested_base) has a real + failure of C (BASE_RED) and the base branch has C green now: an update makes a new merge on that green tip. + Otherwise (why not, base_check): base_check names a check that failed on the base too and is still not green + there, set only when EVERY red check failed on its tested base (one red of the PR's own, or one nothing proves, + means its author fixes it: False), so the PR waits for its base, not for its author. No state and + no loop: after the update the next run names a base where C is green, so a red then is the PR's own and it is + passed over (told); a re-run of the old merge names the old red base again and is updated, which it needs.""" + reds, base = self.red_checks(p), p.get('baseRefName') or '' + if not re.fullmatch(r'[\w./-]+', base): + return 'its base branch is unknown', False + tip = self.commit_checks(f'refs/heads/{base}', cache) + if not tip: + return f'the checks of {base} are unknown (see above)', False + waits = None + for check, c in sorted(reds.items()): + tested = self.tested_base(c) + if not tested: + return f'the base its {check} run tested is unknown (see above)', False + then = self.commit_checks(tested, cache) + if not then: + return f'the checks of {base} commit {tested[:12]} are unknown (see above)', False + was = then[1].get(check, 'not reported') + if was not in BASE_RED: # running, ok, cancelled, skipped, stale...: nothing proves the base was red + return f'{check} was not red on the {base} commit {tested[:12]} its run tested ({was})', False + now = tip[1].get(check, 'not reported') + if now != 'ok' and not waits: # keep looking: a later red of the PR's own is its author's to fix + waits = f'{check} failed on {base} {tested[:12]} too and is not green on {base} now ({now})', check + return waits or (None, None) + # --- steps 2-4 -------------------------------------------------------------------------------------------------- def view(self, n): r = self.gh('pr', 'view', str(n), '--repo', self.repo, '--json', VIEW) @@ -617,13 +709,30 @@ def run(self, dry): if len(prs) >= limit: print(f'WARNING: gh listed {len(prs)} open PRs, the list_limit: GitHub lists the newest first, so the ' 'oldest waiting ones may be missing. Raise list_limit (at most 100) or close old PRs') + cache = {} # commit_checks: the base's checks read once per run for p in sorted(prs, key=lambda p: p.get('createdAt') or ''): n = p['number'] if p.get('isDraft') or not p.get('autoMergeRequest'): continue bad = self.failing(p) - if bad: + if bad and p.get('mergeStateStatus') == 'BEHIND' and self.cfg.update_when_base_was_red: + base = p.get('baseRefName') or 'its base' + why, base_check = self.red_from_old_base(p, cache) + if why is None: + reds = ', '.join(sorted(self.red_checks(p))) + print(f'update #{n} {(p.get("title") or "")[:70]}: required check {reds} failed on a {base} ' + f'commit where it failed too, and it is green on {base} now') + return self.update(n, dry) + print(f'skip #{n}: required check {bad} failed and an update would not fix it yet: {why}') + if base_check: + if not dry: + self.tell_red(n, bad, f'failed as on {base}', + f'{base} failed {base_check} too, so this is not this PR\'s fault: the queue ' + f'updates it by itself once {base_check} is green on {base}') + continue + elif bad: print(f'skip #{n}: required check {bad} failed (its author fixes it)') + if bad: if not dry: self.tell_red(n, bad) continue diff --git a/fork-ci/actions/pr-queue/pr_queue.test.py b/fork-ci/actions/pr-queue/pr_queue.test.py index 2977c5e870c0b..10c3ea9851762 100644 --- a/fork-ci/actions/pr-queue/pr_queue.test.py +++ b/fork-ci/actions/pr-queue/pr_queue.test.py @@ -1001,7 +1001,8 @@ def sweep(self, state='BEHIND', runs=(('2026-11-09T02:00:00Z', 'FAILURE'),), com fail=(), name='review-check'): self.posted, self.updated = [], [] listed = [{'number': 7, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, 'mergeStateStatus': state, - 'createdAt': '2026-11-07T00:00:00Z', 'statusCheckRollup': self.rollup(*runs, name=name)}] + 'createdAt': '2026-11-07T00:00:00Z', 'statusCheckRollup': self.rollup(*runs, name=name), + 'baseRefName': 'main'}] def gh(*a): if a[:2] == ('pr', 'list'): @@ -1013,6 +1014,10 @@ def gh(*a): if a[:2] == ('pr', 'comment'): self.posted.append(a[-1]) return failed('boom') if 'comment' in fail else ok() + if a[:2] == ('api', 'graphql'): # main is green; these runs name no tested base (RedChecksWithTheBaseRule) + return ok(json.dumps({'data': {'repository': {'object': {'oid': 'e9' * 20, 'statusCheckRollup': { + 'contexts': {'nodes': [{'__typename': 'CheckRun', 'name': name, 'status': 'COMPLETED', + 'conclusion': 'SUCCESS', 'startedAt': '2026-11-09T00:00:00Z'}]}}}}}})) raise AssertionError(a) self.q.gh = gh self.q.update = lambda n, d: self.updated.append(n) or 0 @@ -1194,6 +1199,204 @@ def test_a_dry_run_posts_nothing_and_says_what_it_skips(self): self.assertIn('skip #1: up to date, but required check build ended CANCELLED', out) +class RedChecksWithTheBaseRule(RedChecks): + """Every RedChecks case again with update_when_base_was_red on: a red whose run names no tested base (its job has + no tested-base step) is the PR's own, as with the rule off.""" + def setUp(self): + super().setUp() + self.q.cfg = config.parse(json.dumps({**CFG_RAW, 'update_when_base_was_red': True})) + + +class RedOnOldBase(unittest.TestCase): + """update_when_base_was_red: a BEHIND PR red only because the base commit its run merged with was red is updated; a + red of its own, or a base that proves nothing (cancelled, stale, skipped, running), still skips it. The base comes + from the run (each job's tested-base annotation), never from clocks: jobs start late, and a re-run tests its old + merge again. The source project's draft fix, ported (README.md, "Where the rules come from"). + main: M0 (build green) -> M1 (build FAILURE) -> M2 (green again, the tip).""" + H, H2 = 'a7' * 20, 'b8' * 20 + M0, M1, M2 = 'e0' * 20, 'e1' * 20, 'e2' * 20 + RUN, RERUN = '2026-11-07T22:30:00Z', '2026-11-08T02:20:00Z' + + def setUp(self): + d = tempfile.mkdtemp(prefix='pr-queue-base-red-') + self.addCleanup(shutil.rmtree, d, True) + self.q = make_queue(d, config.parse(json.dumps({**CFG_RAW, 'update_when_base_was_red': True}))) + + def main_of(self, m1='FAILURE', tip='SUCCESS', check='build', other=None): + """{sha: {check: conclusion}} of main's commits; M2 is the tip.""" + return {self.M0: {check: 'SUCCESS', **(other or {})}, self.M1: {check: m1, **(other or {})}, + self.M2: {check: tip, **(other or {})}} + + def sweep(self, prs, main, state='BEHIND', dry=False, notes_fail=False, main_fails=False, more=False, comments=()): + """prs: [(number, head, {name: [(startedAt, conclusion, tested base or None), ...]})]; main: main_of().""" + self.posted, self.updated, self.tip_reads, self.api = [], [], 0, 0 + notes, listed = {}, [] + for n, head, runs in prs: + rollup = [] + for name, rs in runs.items(): + for t, c, base in rs: + job = str(1000 + len(notes)) + notes[job] = [{'title': Q.TESTED_BASE, 'message': base}] if base else [] + rollup.append({'__typename': 'CheckRun', 'workflowName': 'ci', 'name': name, 'startedAt': t, + 'conclusion': c, 'detailsUrl': f'https://github.com/{REPO}/actions/runs/9/job/{job}'}) + listed.append({'number': n, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, + 'mergeStateStatus': state, 'createdAt': f'2026-11-07T00:00:0{n}Z', 'statusCheckRollup': rollup, + 'headRefOid': head, 'baseRefName': 'main'}) + heads = {n: head for n, head, _ in prs} + + def gh(*a): + if a[:2] == ('pr', 'list'): + return ok(json.dumps(listed)) + if a[:2] == ('pr', 'view'): + return ok(json.dumps({'headRefOid': heads[int(a[2])], 'comments': [own(c) for c in comments]})) + if a[:2] == ('pr', 'comment'): + self.posted.append(a[-1]) + return ok() + self.api += a[0] == 'api' + if a[0] == 'api' and a[1].endswith('/annotations?per_page=100'): + return failed() if notes_fail else ok(json.dumps(notes[a[1].split('/')[-2]])) + if a[:2] == ('api', 'graphql'): + if main_fails: + return failed() + expr = next(x[5:] for x in a if x.startswith('expr=')) + sha = self.M2 if expr == 'refs/heads/main' else expr + self.tip_reads += expr == 'refs/heads/main' + if sha not in main: + return ok(json.dumps({'data': {'repository': {'object': None}}})) + nodes = [{'__typename': 'CheckRun', 'name': k, 'status': 'COMPLETED' if c else 'IN_PROGRESS', + 'conclusion': c, 'startedAt': self.RUN, 'checkSuite': {'workflowRun': {'workflow': {'name': 'ci'}}}} + for k, c in main[sha].items()] + return ok(json.dumps({'data': {'repository': {'object': {'oid': sha, 'statusCheckRollup': { + 'contexts': {'pageInfo': {'hasNextPage': more}, 'nodes': nodes}}}}}})) + raise AssertionError(a) + self.q.gh = gh + self.q.update = lambda n, d: self.updated.append((n, d)) or 0 + return run_quiet(self.q.run, dry) + + def one(self, runs, main=None, **kw): + return self.sweep([(7, self.H, runs)], main or self.main_of(), **kw) + + def test_a_red_from_a_red_base_that_is_green_now_is_updated(self): # the issue + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'SUCCESS', self.M1)]}) + self.assertEqual((code, self.updated, self.posted), (0, [(7, False)], []), out) + self.assertIn('required check build failed on a main commit where it failed too, and it is green on main now', + out) + + def test_off_by_default_the_red_is_the_prs_own_and_nothing_more_is_read(self): # a project without the notes + self.q.cfg = CFG + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}) + self.assertEqual((code, self.updated, len(self.posted), self.api), (0, [], 1, 0), out) + self.assertIn('Fix the cause and push', self.posted[0]) + + def test_a_job_that_started_late_is_judged_by_the_base_it_tested_not_its_clock(self): + # its jobs queued for hours: it started after M2 went green, but its merge was made on M1 + code, out = self.one({'build': [(self.RERUN, 'FAILURE', self.M1)]}) + self.assertEqual((self.updated, self.posted), ([(7, False)], []), out) + # the other way round: it started while M1 was the tip, but its merge was made on the green M0 before + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M0)]}) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'build was not red on the main commit {self.M0[:12]} its run tested (ok)', out) + + def test_a_re_run_of_the_old_merge_must_be_updated(self): # a re-run tests the old red base again + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1), (self.RERUN, 'FAILURE', self.M1)]}) + self.assertEqual((self.updated, self.posted), ([(7, False)], []), out) + + def test_its_red_after_the_update_is_its_own_and_is_told_no_loop(self): # a real new merge head + # the update made head H2; its run merged H2 with the green M2 and still failed: its own red, told once + code, out = self.sweep([(7, self.H2, {'build': [(self.RERUN, 'FAILURE', self.M2)]})], self.main_of()) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'build was not red on the main commit {self.M2[:12]} its run tested (ok)', out) + self.assertIn(f'required check build is red on {self.H2[:12]}', self.posted[0]) + self.assertIn('Fix the cause and push', self.posted[0]) + + def test_a_base_check_that_proves_nothing_is_not_base_was_red(self): + for m1, said in (('CANCELLED', 'CANCELLED'), ('STALE', 'STALE'), ('STARTUP_FAILURE', 'STARTUP_FAILURE'), + ('TIMED_OUT', 'TIMED_OUT'), ('SKIPPED', 'ok'), (None, 'running')): + with self.subTest(main=m1): + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, self.main_of(m1=m1)) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'its run tested ({said})', out) + self.assertIn('Fix the cause and push', self.posted[0]) + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, self.main_of(check='test')) + self.assertEqual(self.updated, [], out) + self.assertIn('(not reported)', out) + + def test_a_base_still_red_waits_and_says_the_base_is_the_reason(self): # not "fix and push" + for tip, said in (('FAILURE', 'FAILURE'), ('CANCELLED', 'CANCELLED'), (None, 'running')): + with self.subTest(tip=tip): + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, self.main_of(tip=tip)) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'is not green on main now ({said})', out) + self.assertIn(f'required check build failed as on main on {self.H[:12]}', self.posted[0]) + self.assertIn('not this PR', self.posted[0]) + self.assertIn('updates it by itself once build is green on main', self.posted[0]) + self.assertNotIn('Fix the cause and push', self.posted[0]) + told = self.posted[0] + self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, self.main_of(tip='FAILURE'), comments=[told]) + self.assertEqual(self.posted, [], 'told once per head') + + def test_every_red_check_must_come_from_the_base(self): # one red of its own keeps it skipped + runs = {'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'FAILURE', self.M1)]} + code, out = self.one(runs, self.main_of(other={'test': 'SUCCESS'})) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'test was not red on the main commit {self.M1[:12]} its run tested (ok)', out) + + def test_a_red_of_its_own_beside_a_base_still_red_is_its_authors(self): # r3 major: never "not this PR's fault" + main = {k: {**v, 'test': 'SUCCESS'} for k, v in self.main_of(tip='FAILURE').items()} + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'FAILURE', self.M1)]}, main) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'test was not red on the main commit {self.M1[:12]} its run tested (ok)', out) + self.assertNotIn('not this PR', self.posted[0]) + self.assertIn('Fix the cause and push', self.posted[0]) + + def test_the_wait_notice_names_the_check_that_is_red_on_the_base(self): # r3 minor: not just the first red + main = self.main_of(other={'test': 'FAILURE'}) # build: red on M1, green now; test: red on M1 and now + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'FAILURE', self.M1)]}, main) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn('updates it by itself once test is green on main', self.posted[0]) + + def test_an_unknown_tested_base_or_base_is_said_and_skipped(self): # unknown is never "the base was red" + for kw, base, said in (({}, None, 'tested is unknown'), ({'notes_fail': True}, self.M1, 'HTTP 502'), + ({'main_fails': True}, self.M1, 'HTTP 502'), ({}, 'ff' * 20, 'are unknown'), + ({'more': True}, self.M1, 'more than 100, not all read')): + with self.subTest(said=said): + code, out = self.one({'build': [(self.RUN, 'FAILURE', base)]}, **kw) + self.assertEqual((code, self.updated, len(self.posted)), (0, [], 1), out) + self.assertIn(said, out) + self.assertIn('Fix the cause and push', self.posted[0]) + + def test_a_job_with_no_note_two_notes_or_no_job_has_no_tested_base(self): + url = f'https://github.com/{REPO}/actions/runs/9/job/5' + for notes, said in (([{'title': Q.TESTED_BASE, 'message': self.M1}, {'title': Q.TESTED_BASE, 'message': self.M0}], + 'names 2 tested bases'), + ([{'title': Q.TESTED_BASE, 'message': 'main'}], 'names 0 tested bases'), + ([{'title': 'lint', 'message': 'x'}] * 100, 'may be on a later page')): + with self.subTest(said=said): + self.q.gh = lambda *a, n=notes: ok(json.dumps(n)) + got, out = run_quiet(self.q.tested_base, {'name': 'build', 'detailsUrl': url}) + self.assertIsNone(got) + self.assertIn(said, out) + got, out = run_quiet(self.q.tested_base, {'name': 'build', 'detailsUrl': 'https://ci.example/build/5'}) + self.assertEqual(got, None) + self.assertIn('no Actions job', out) + + def test_the_base_is_read_once_per_run(self): + prs = [(7, self.H, {'build': [(self.RUN, 'FAILURE', self.M0)]}), + (8, self.H2, {'build': [(self.RUN, 'FAILURE', self.M1)]})] + code, out = self.sweep(prs, self.main_of()) + self.assertEqual((self.updated, len(self.posted), self.tip_reads), ([(8, False)], 1, 1), out) + + def test_an_up_to_date_red_pr_never_asks_for_the_base(self): # an update would not change its base + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, state='BLOCKED') + self.assertEqual((self.updated, len(self.posted), self.api), ([], 1, 0), out) + + def test_a_dry_run_says_it_would_update_and_posts_nothing(self): + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, dry=True) + self.assertEqual((self.updated, self.posted), ([(7, True)], []), out) + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)]}, self.main_of(tip='FAILURE'), dry=True) + self.assertEqual((self.updated, self.posted), ([], []), out) + + # --- the source project's lens-review cases for the queue ------------------------------------------------------------ HEAD = 'def wipe(player, island):\n' READ = HEAD + " if is_admin(player):\n log.info('wipe')\n wipe_island(island)\n" # only an admin wipes diff --git a/fork-ci/actions/pr-queue/tested-base/action.yml b/fork-ci/actions/pr-queue/tested-base/action.yml new file mode 100644 index 0000000000000..e0c97127e104c --- /dev/null +++ b/fork-ci/actions/pr-queue/tested-base/action.yml @@ -0,0 +1,19 @@ +name: pr-queue tested base +description: >- + On a pull_request run, notes the base commit this job tests (the first parent of GitHub's merge commit) as an + annotation that pr-queue reads (config update_when_base_was_red). Run it right after the checkout of the merge + commit, in the job of every required check. Writes no note on other events or another checkout. Needs git and + python3. +inputs: + path: + description: Where the merge commit is checked out (actions/checkout's path). + required: false + default: "." +runs: + using: composite + steps: + - name: Note the base this run tests + shell: bash + env: + CHECKOUT_PATH: ${{ inputs.path }} + run: python3 -I "$GITHUB_ACTION_PATH/tested_base.py" --path "$CHECKOUT_PATH" diff --git a/fork-ci/actions/pr-queue/tested-base/tested_base.py b/fork-ci/actions/pr-queue/tested-base/tested_base.py new file mode 100644 index 0000000000000..101c510962d3a --- /dev/null +++ b/fork-ci/actions/pr-queue/tested-base/tested_base.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Notes which base commit a pull_request run tests, for pr_queue.py's update_when_base_was_red (../README.md). + +A pull_request run tests GitHub's merge commit of the PR head into its base, and that commit's first parent is the +base commit the run tested. That stays true for a re-run (it tests the same merge again) and for a job that started +late, which a clock would get wrong. The note is an annotation on the job, `::notice title=pr_queue tested base::`, +which the queue reads. + +It is written only when the checkout is exactly that merge commit: HEAD is GITHUB_SHA, a commit with two parents whose +second is the event's PR head. Anything else (another checkout, no checkout, an odd event file) writes a warning and no +note, so the queue reads the base as unknown and treats a red as the PR's own; other events write one plain line. It +always exits 0: a missing note can only cost an update, never cause a wrong one. + tested_base.py [--path DIR] +""" +import argparse, json, os, re, subprocess, sys + +TITLE = 'pr_queue tested base' # pr_queue.TESTED_BASE (tested_base.test.py pins the two together) +SHA = re.compile(r'[0-9a-f]{40}') + + +def git(path, *a): + r = subprocess.run(['git', '-C', path, *a], text=True, capture_output=True) + return r.stdout.strip() if r.returncode == 0 else None + + +def problem(env, path): + """(None, base) when the checkout at `path` is the run's merge commit, else (why not, None).""" + try: + with open(env.get('GITHUB_EVENT_PATH') or '', encoding='utf-8') as f: + head = json.load(f)['pull_request']['head']['sha'] + except (OSError, ValueError, KeyError, TypeError): + head = None + sha = env.get('GITHUB_SHA') or '' + if not SHA.fullmatch(head or ''): + return 'the event file names no pull request head', None + if not SHA.fullmatch(sha): + return f'GITHUB_SHA {sha[:40]!r} is no commit', None + checked_out = git(path, 'rev-parse', '--verify', '-q', 'HEAD^{commit}') + if checked_out != sha: + return f'{path} has {checked_out or "no commit"} checked out, not the run\'s merge commit {sha}', None + raw = git(path, 'cat-file', 'commit', sha) or '' # the raw object: its parents even in a shallow clone + parents = re.findall(r'^parent ([0-9a-f]{40})$', raw.split('\n\n', 1)[0], re.M) + if len(parents) != 2: + return f'{sha[:12]} has {len(parents)} parents, so it is no merge of the pull request into its base', None + if parents[1] != head: + return f'{sha[:12]} merges {parents[1][:12]}, not the pull request head {head[:12]}', None + return None, parents[0] + + +def main(argv=None, env=None): + env = os.environ if env is None else env + p = argparse.ArgumentParser(prog='tested_base.py', description='Note the base a pull_request run tests.') + p.add_argument('--path', default='.', help='where the merge commit is checked out (default: .)') + a = p.parse_args(argv) + event = env.get('GITHUB_EVENT_NAME') or '' + if event != 'pull_request': + print(f'{TITLE}: a {event or "no"} event, not pull_request: no note (only pull_request runs test a merge)') + return 0 + why, base = problem(env, a.path) + if why: + print(f'::warning title={TITLE} unknown::{why}. pr_queue reads this run\'s base as unknown') + else: + print(f'::notice title={TITLE}::{base}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/fork-ci/actions/pr-queue/tested-base/tested_base.test.py b/fork-ci/actions/pr-queue/tested-base/tested_base.test.py new file mode 100644 index 0000000000000..4e67ec314a8b4 --- /dev/null +++ b/fork-ci/actions/pr-queue/tested-base/tested_base.test.py @@ -0,0 +1,140 @@ +"""Tests of tested_base.py and its action (run: python3 fork-ci/actions/pr-queue/tested-base/tested_base.test.py). + +Real commits: a merge commit made the way GitHub makes a pull request's (parents: the base, then the PR head), checked +out fully and as a depth-1 clone. The note it writes is fed to pr_queue.py's reader, so the writer and the reader +cannot drift apart. Nothing here starts gh or reaches the network. +""" +import json, os, shutil, subprocess, sys, tempfile, unittest, unittest.mock + +sys.dont_write_bytecode = True +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.dirname(HERE)) +import tested_base as T # noqa: E402 +import pr_queue as Q # noqa: E402 + +GIT_ENV = {**os.environ, 'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@t', 'GIT_COMMITTER_NAME': 't', + 'GIT_COMMITTER_EMAIL': 't@t', 'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_TERMINAL_PROMPT': '0'} +os.environ.update({k: GIT_ENV[k] for k in ('GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM', 'GIT_ALLOW_PROTOCOL')}) + + +def git(cwd, *a): + r = subprocess.run(['git', *a], cwd=cwd, text=True, capture_output=True, env=GIT_ENV) + if r.returncode != 0: + raise RuntimeError(f'git {a}: {r.stderr}') + return r.stdout.strip() + + +class TestedBase(unittest.TestCase): + """base B0 -> B1 (the tip the merge was made on); the PR head H on B0; M = GitHub's merge (parents B1, H).""" + def setUp(self): + self.d = tempfile.mkdtemp(prefix='tested-base-') + self.addCleanup(shutil.rmtree, self.d, True) + self.repo = os.path.join(self.d, 'repo') + git(self.d, 'init', '-q', '-b', 'main', self.repo) + self.B0 = self.commit({'a': '1\n'}) + self.B1 = self.commit({'a': '1\n', 'b': '2\n'}, self.B0) + self.H = self.commit({'a': '1\n', 'c': '3\n'}, self.B0) + self.M = self.commit({'a': '1\n', 'b': '2\n', 'c': '3\n'}, self.B1, self.H) + git(self.repo, 'branch', 'merge', self.M) + git(self.repo, 'checkout', '-q', 'merge') + self.event = os.path.join(self.d, 'event.json') + self.write_event({'pull_request': {'head': {'sha': self.H}}}) + + def commit(self, files, *parents): + for name in os.listdir(self.repo): + if name != '.git': + os.remove(os.path.join(self.repo, name)) + for name, text in files.items(): + with open(os.path.join(self.repo, name), 'w') as f: + f.write(text) + git(self.repo, 'add', '-A') + tree = git(self.repo, 'write-tree') + return git(self.repo, 'commit-tree', tree, *sum((['-p', p] for p in parents), []), '-m', 'c') + + def write_event(self, payload, raw=None): + with open(self.event, 'w') as f: + f.write(raw if raw is not None else json.dumps(payload)) + + def run_it(self, path=None, event='pull_request', sha=None): + env = {'GITHUB_EVENT_NAME': event, 'GITHUB_EVENT_PATH': self.event, 'GITHUB_SHA': sha or self.M} + r = subprocess.run([sys.executable, '-I', os.path.join(HERE, 'tested_base.py'), '--path', path or self.repo], + text=True, capture_output=True, env={**GIT_ENV, **env}) + self.assertEqual(r.returncode, 0, r.stderr) # never fails the job: no note is read as "unknown" + return r.stdout + + def assertNoNote(self, out, said): + self.assertNotIn(f'title={T.TITLE}::', out) + self.assertIn(f'::warning title={T.TITLE} unknown::', out) + self.assertIn(said, out) + + def test_the_merge_commit_names_its_first_parent(self): + self.assertEqual(self.run_it(), f'::notice title={T.TITLE}::{self.B1}\n') + + def test_a_depth_1_clone_still_names_it(self): # actions/checkout's default fetch-depth + shallow = os.path.join(self.d, 'shallow') + git(self.d, 'clone', '-q', '--depth', '1', '--branch', 'merge', 'file://' + self.repo, shallow) + with self.assertRaises(RuntimeError): + git(shallow, 'rev-parse', '--verify', 'HEAD^1^{commit}') # the parent is not there + self.assertEqual(self.run_it(shallow), f'::notice title={T.TITLE}::{self.B1}\n') + + def test_other_events_write_no_note_and_no_warning(self): + for event in ('push', 'pull_request_target', 'merge_group', 'schedule', ''): + with self.subTest(event=event): + out = self.run_it(event=event) + self.assertNotIn('::', out) + self.assertIn('no note', out) + + def test_another_checkout_writes_no_note(self): + git(self.repo, 'checkout', '-q', '--detach', self.H) # a job that checked out the PR head: no merge tested + self.assertNoNote(self.run_it(), f'has {self.H} checked out, not the run\'s merge commit') + self.assertNoNote(self.run_it(os.path.join(self.d, 'nothing-here')), 'has no commit checked out') + + def test_a_commit_that_is_no_merge_of_the_pr_head_writes_no_note(self): + git(self.repo, 'checkout', '-q', '--detach', self.B1) + self.assertNoNote(self.run_it(sha=self.B1), 'has 1 parents') + other = self.commit({'d': '4\n'}, self.B1, self.B0) # a merge, but not of the event's PR head + git(self.repo, 'checkout', '-q', '--detach', other) + self.assertNoNote(self.run_it(sha=other), f'not the pull request head {self.H[:12]}') + + def test_an_odd_event_file_or_sha_writes_no_note(self): + for payload, raw in (({}, None), ({'pull_request': {'head': {'sha': 'main'}}}, None), (None, '{')): + with self.subTest(payload=payload, raw=raw): + self.write_event(payload, raw) + self.assertNoNote(self.run_it(), 'names no pull request head') + self.write_event({'pull_request': {'head': {'sha': self.H}}}) + self.assertNoNote(self.run_it(sha='HEAD'), "GITHUB_SHA 'HEAD' is no commit") + + def test_the_queue_reads_the_note_and_ignores_the_warning(self): # the writer and the reader agree + self.assertEqual(T.TITLE, Q.TESTED_BASE) + + def annotations(out): # what GitHub keeps of each `::level title=...::message` line + notes = [] + for line in out.splitlines(): + if line.startswith('::'): + head, message = line[2:].split('::', 1) + notes.append({'title': head.split(' ', 1)[1].split('=', 1)[1], 'message': message}) + return notes + q = object.__new__(Q.Queue) # only tested_base(): no config, STATE or cache + q.repo = 'owner/proj' + job = {'name': 'build', 'detailsUrl': 'https://github.com/owner/proj/actions/runs/9/job/5'} + for out, want in ((self.run_it(), self.B1), (self.run_it(sha=self.B1), None)): + q.gh = lambda *a, n=annotations(out): Q.subprocess.CompletedProcess(a, 0, json.dumps(n), '') + with open(os.devnull, 'w') as null, unittest.mock.patch('sys.stdout', null): + self.assertEqual(q.tested_base(job), want) + + def test_the_action_runs_the_script_on_its_path_input(self): + with open(os.path.join(HERE, 'action.yml')) as f: + text = f.read() + self.assertIn('CHECKOUT_PATH: ${{ inputs.path }}', text) + run = 'python3 -I "$GITHUB_ACTION_PATH/tested_base.py" --path "$CHECKOUT_PATH"' + self.assertIn(f'run: {run}\n', text) + r = subprocess.run(['bash', '-c', run], text=True, capture_output=True, + env={**GIT_ENV, 'GITHUB_ACTION_PATH': HERE, 'CHECKOUT_PATH': self.repo, + 'GITHUB_EVENT_NAME': 'pull_request', 'GITHUB_EVENT_PATH': self.event, 'GITHUB_SHA': self.M}) + self.assertEqual((r.returncode, r.stdout), (0, f'::notice title={T.TITLE}::{self.B1}\n'), r.stderr) + + +if __name__ == '__main__': + unittest.main() From 8a719eae49e664275990a2a5181787b7be6597ce Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:09:18 +0100 Subject: [PATCH 21/27] docs(fork): host tools design: agents on a person's own computer (limited account, fast own seeing/acting layer, runner-side activity) (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(fork): host tools design: agents on a person's own computer through a limited OS account Design only, no code. Builds on upstream linked environments, MCP tool forwarding, the background service and server settings; the fork adds a host toolkit, a session helper in the desktop app, per-OS guard tasks and one availability setting. Co-Authored-By: Claude Opus 5.5 * docs(fork): host tools r2: own speed-first seeing/acting layer, runner-side activity, tray and wind-down, link renewal Owner feedback 2026-11-13: - no activity gate on host tools; activity-aware scale-down moved to runners (§11) - tray icon with pause, wind down ("safe to shut down") and kill switch (§12) - link expiry checked upstream (30-day MCP-client session, no refresh grant); fork option: refresh while in use, or a longer-lifetime setting (§10) - seeing and acting: own agent-native engine, most direct route first and pixels last; batches with on-computer waits, diffed text snapshots, recipes, latency budget and benchmark, why not classic computer use (§5-§8) - safety: per-app see/act policy, built-in refusals, indicator excluded from captures, instant take-over, held-input journal, idempotent batches (§6.5, §7.2) - invisible by default as a tested requirement (§6.6); speculative parallel batches for side-effect-free alternatives (§7.4) - failure table rows for wind-down, runner scale-down, take-over, mid-drag crash, half-done UI action, speculative batches Co-Authored-By: Claude Opus 5.5 * Host tools design r3: lens blockers and majors, goal-driven scope, input ladder Owner scope A (whole computer except sensitive things, one batched ask), separate host and agent accounts with a command broker, link-safe file operations, network deny classes, guard-owned policy store, writer-aware undo, a four-rung input ladder, private-copy speculative runs sized from free hardware, long link lifetime with one-click extension and device binding, runner budgets from measured free hardware, wind down without a timer, power-loss recovery. Co-Authored-By: Claude Opus 5.5 * Host tools design r4: protected areas, acting as you, no direct agent internet Folds in the owner decisions of 2026-11-13 on the second security review (blocker N1, majors N2-N10, minors n1-n8): acting in the UI acts as you, with OS-enforced protected areas, OK per action and an activity log; a forwarded-tool allowlist; auto-run quarantine and rename denies; frozen copies for approved runs; no direct internet for the agent group; durable narrowing; take-over pause with no idle timer; 365-day bound links; runner isolation. Failure table, tests and open points updated. Co-Authored-By: Claude Opus 5.5 * Host tools design r5: security r3 and correctness fixes, owner answers 1-7 - User-writable system spots get deny entries for the agent group and the guard's watch for the owner (owner answer 7, lens P1). - Engine worker and the agent's own loopback RDP session for rung 3; never depends on the owner's sign-in (spike 2026-11-13). - Network: only named services on linked machines, guard resolver, openings ledger; take-over pause covers the whole computer, Continue needs consent. - Undo store sized to not fill (owner answer 6); "Run" defined (answer 1); UAC Always notify by default (answer 4); driver measure-first (answer 5). - Held entries' processes ended, service-started processes marked, marks and baselines journalled, lock leases, key tombstones, layer_gone, link expiry sync on the linking side, DPoP always; §15 rows and §16 tests for each. Co-Authored-By: Claude Opus 5.5 * Host tools design r6: owner answers 2026-11-14, security r4 and correctness fixes No Remote Desktop session for the agent (password set once, batch logon only, the guard starts the broker and the engine worker); the T3 confirm replaces the OS prompt for Continue and a renewed ask (real input only, bound to the item, phone or other computer counts); later screen options in one section; named services only on linked machines with a worked example. Closes the security r4 blocker and majors (guard proxy for named hosts, DCOM denies, service-path probes, deny execute on agent-placed files, guard copies for held changes, DPoP body signing, persisted replay cache) and the correctness majors (row expiry for link extension, no forced snapshots, unattended restart). §15 recovery rows and §16 tests for every changed rule. Co-Authored-By: Claude Opus 5.5 * Host tools design r7: security r5 and correctness r2 fixes, pending owner defaults - Proxy checks every resolved address against the network floor, pins it, runs as a low-rights account. - T3 confirm: one-time nonce per item, only the panel's own raw input (no accessibility actions or window messages), one item per kind with rate limits, phone passkey on the guard's own page, a separate guard confirm key, X11 confirms on the phone. - No agent process outside the guard's job: task, WMI and DCOM starts denied up front, probed. - Unknown writers and full reserved area fail closed; stale deny pass freezes running processes. - Host T3 task uses S4U; guard creates the broker pipe and the agent window station. - Three stale consent lines fixed; owner questions 1-3 of 2026-11-14 written as pending defaults. Co-Authored-By: Claude Opus 5.5 * Host tools design r7: owner answers 1-3 of 2026-11-14 recorded as decided Co-Authored-By: Claude Opus 5.5 * Host tools design r8: simpler designs for security r6 and correctness r3 - DCOM: per-AppID denies computed from the descriptors (machine-wide limits untouched, measured allow list), Secondary Logon denied too - Service identities t3-net and t3-confirm (virtual service accounts / DynamicUser); t3-net may reach only the system DNS servers on port 53 - Floor holds every tailnet address; named services are separate allow rules for direct connections only; proxy accepts CONNECT only - SMB and printers to the internet blocked computer-wide (pending owner) - Hardened confirm only on a phone or another computer (pending owner); panel capture-excluded, no clip/warp, Linux keyboard grab; phone page only from registered phones, exact origin check, single-use registration, guard identity key from pairing - Held changes neutralised in place (no quota, no unknown share); clean guard stops count as yours; retaken copies unverified - Ledger records originals once; uninstall removes only the guard's entries; WebClient stopped and checked daily - Freeze: daily re-apply freezes nothing, job cap with no breakaway, macOS ends, leftovers ended at start; secret opens race-free Co-Authored-By: Claude Opus 5.5 * Host tools design r9: scope cut per stuck audit Defer the remote confirm (phone and second computer: rule 7, the t3-confirm page and identity, registration, guard confirm and identity keys) to its own design, listed in section 17. The hardened confirm is the OS's own admin prompt in v1; X11 gets no on-screen acting and no high-risk OK. Drop the lock while acting (leases, start-time lock restore); the 2-min acting window and the 30-min rule stay as plain time windows. Add rule 1 (the guard only takes away on places another account can write, by file identity) and rule 2 (nothing running as you changes who confirms). Label controls boundary or tripwire in section 14. Fold the one-line minors (per-user COM in the DCOM walk, lost or old ledger, S4U re-register, SMB residual covers the tailnet); list the rest. Pending questions now point to issues 28 and 29. Co-Authored-By: Claude Opus 5.5 * docs(fork): host tools: gives run as the place's writer; polkit not a protected surface Final text fix (round cap) of the r9 scope cut, from the last correctness and security diff lenses: - Audit rule 1 (§3.4): a give (owner, permissions, restored content, Keep) runs as the least-privileged account that can write the place (you), never as an administrator, SYSTEM or root; refused if that account cannot. Only exception: the guard undoing its own take-away on the same object, identity incl. generation and link count 1 matched through the checked handle. "Make it mine" copies as you. The link rules now cover every folder another account can write. - §4.2/§14: the Linux polkit agent runs as you and can be replaced, so it counts as the hardened confirm only where the build proves it cannot; otherwise those items are refused, as on X11. Where the hardened confirm is refused, the way back is re-running the admin install. - One-line minors: restore and Keep "as you", waiting for sign-in without a token; acting window state in §15; wording at §3.1 reach row, §4.2, §14 links row, §17. Co-Authored-By: Claude Opus 5.5 * docs(fork): format host-tools.md (table padding only) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/fork/host-tools.md | 2191 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 2191 insertions(+) create mode 100644 docs/fork/host-tools.md diff --git a/docs/fork/host-tools.md b/docs/fork/host-tools.md new file mode 100644 index 0000000000000..9472da2ac4541 --- /dev/null +++ b/docs/fork/host-tools.md @@ -0,0 +1,2191 @@ +# Host tools: agents on a person's own computer (design, no code) + +Status: design for review, revision 9: a scope cut after a stuck audit. The remote confirm (phone and second +computer) is deferred to its own design (§17); rare high-risk confirms use the OS's own admin prompt; the lock while +acting is dropped (the watch and the acting window stay); two rules are added: the guard only takes away on places +another account can write (§3.4), and nothing running as you changes who confirms (§4.2). Two owner questions are +pending with their recommended defaults (§17). Fork feature, not upstream. Nothing here names a real machine, person or address; those live in each install's local settings. + +## In short + +- **What it lets agents do:** you give an agent on one of your machines (for example a headless server) a goal on + another of your computers: "I want X done". The agent works out the fastest, best route itself: files, commands, the + apps' own automation, app-side bridges, accessibility elements, and the mouse and keyboard only as the last resort. +- **Scope (owner decision 2026-11-13):** the agent may use the **whole computer except sensitive things**: keys, `.env` + files, credentials, password fields and secret stores. It may read system areas too (not their secrets). It looks + only at what the task needs. When it needs something more specific (the short high-risk list in §4.2), it **asks + once with a list**: "Can I use X, Y and Z?". Your answers are remembered for the scope you choose. This replaces a + per-app allow list. +- **Speed first:** the computer runs a whole list of steps locally, with waits like "until this button appears", and + sends back only what changed. One network hop and one model turn do the work that takes classic computer use dozens + of screenshot-and-click turns (§7, §8). When the agent is unsure which of two ways works, it tries both at once on + private copies and keeps the one that worked (§7.4). +- **How the agent works (owner decision 2026-11-14):** through **commands** as its own limited account, **its own + hidden browser**, **your apps with your OK**, and **your other linked machines**. It has no screen or desktop session + of its own on Windows or macOS in v1, and its account never signs in to one; it still does its work through these + four routes. Own-screen options for later are listed with what each must prove first (§6.9). +- **Your mouse stays yours:** the agent clicks and types through the least intrusive way that works: element actions, + then events sent to one window, then a hidden screen, and only last the real mouse and keyboard on your desktop + (§6.4). +- **Invisible by default:** commands, scripts and batches run in the background with no window shown and no focus + taken. Only UI work you asked for touches the visible screen, and a test checks that nothing else ever does (§6.8). +- **How it is kept safe:** commands run as a separate, limited **agent account** that the OS keeps out of your + sensitive files and away from T3's own data (T3 itself runs as a second account). The rules live in a store the + agent cannot change; widening them needs you. +- **Said openly: in your apps, the agent acts as you** (owner decision 2026-11-13). Through an app's file dialog, a + link or a launcher it can run any file with your rights, including one it wrote, and nothing marks or asks first. + What holds then: **protected areas** (system folders, other users' profiles and every autostart and persistence + point, including the system places the OS leaves writable to users) are never written, deleted or run without your + OK for that one action, enforced by the OS and by deny entries where it can be, otherwise held by the guard (§4.4); + and a **log of every file the agent wrote or ran** is in the tray. +- **While the agent uses your screen:** a clear on-screen sign shows it. When it drives your real mouse and keyboard, + **any real click or key press from you takes control back at once**, and acting on your screen stays paused until + you say continue (background commands go on). Continue is confirmed in T3 only, with no Windows prompt, in a way the + agent cannot fake (§4.2). The rare high-risk confirms (full rights, admin, protected areas) use the OS's own admin + prompt on its protected screen (§4.2). Everything is logged. +- **Network:** the agent account has **no direct internet** by default. Web search and fetch run on the server; the + agent account reaches only the services you name on your own linked machines, and a goal that needs more asks once + (§3.5). +- **What you click:** the one batched ask, when the agent needs something from the high-risk list (installs, admin + and protected-area actions are on it). Nothing else asks. +- **What you can always do:** from the tray icon: pause, **wind down** (start nothing new, finish or park what runs, + then "safe to shut down"), the kill switch, and revoke every link; undo the agent's file changes of up to the last 24 + hours (§4.3 rule 6). +- **Power can go at any time:** every part recovers on the next start (§12.3, §15). +- **Links between your machines** expire after 365 days and extend with one click from the tray or any linked + machine, without new pairing codes; the strong controls are against outsiders (§10). +- **Off by default for new projects:** each project gets host tools only when you switch them on (§4.3 rule 9). +- **Background runners** (CI jobs, builds) use the hardware you leave free, minus a small buffer, and shrink when you + need it back (§11). Host tools themselves never slow down because you are busy. + +## 1. Parts + +| Part | Where it runs | Built from | What it does | +| ---------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Host environment | the computer, as the **host account** (§3) | upstream T3 server, installed with upstream's background service | serves the host tools over the link; owns T3's data; runs no agent code | +| Command broker | the computer, as the **agent account** (§3), started by the guard with a batch logon | **fork**, small, per OS | starts every agent command as the agent account; accepts requests only from the host account | +| Linked environment | between the agent machine and the computer | upstream peer links (`docs/internals/remote.md`, "Peer links") | transport, auth, routes (Tailscale, LAN), revocation, access ceiling, expiry (§10) | +| `host` toolkit | inside T3's MCP server | **fork**, one toolkit like `toolkits/environment/` | typed tools (§9); each takes `environmentId`, so upstream forwards it to the computer | +| Session helper | the computer, in **your** desktop session, as you | **fork** add-on to the T3 desktop app (main process) | tray (§12), the batched ask (§4.2), policy view; starts the engine; hosts the bridge endpoint | +| Engine | the computer, as you, started by the session helper | **fork**, new, one native binary per OS (§13) | the seeing and acting layer (§6-§8): app state, accessibility tree cache, batches, waits, input, screenshots | +| Engine worker | the computer, as the **agent account**, in the guard's agent job, started by the guard (§6.1) | **fork**, the same binary in worker mode | owns the agents' headless browser (§6.5); on Linux also the agent's own headless display (rung 3, §6.4); never in your session | +| Guard service | the computer, as the system (admin) | **fork**, per OS, installed once | owns the protected store, snapshots, the authoritative log, the kill switch, permission re-application, the protected-area watch (§4.4) | +| Confirm panel | the computer, in **your** desktop session, started by the guard at system integrity | **fork**, small native window in T3's style | the T3 confirm for Continue and other ordinary widening (§4.2): accepts only real input, bound to one item; no web content; never the hardened confirm | +| Protected store | the computer, owned by the guard service | **fork**, JSON, shown in T3's settings UI and the tray | reach map, secret patterns, ask answers, recipes, approved scripts, named commands, bridges | +| Availability, project grants | the agent machine's T3 settings | **fork**, server settings | per linked computer: on / off / off until; per project: may this project use host tools (§4.3 rule 9) | +| Activity service | the computer, in your session (part of the helper) | **fork** (§11) | measures free hardware for background runners and for sizing speculative runs; never gates host tools | +| Network proxy and resolver | the computer, as the `t3-net` service identity (§3), started by the guard | **fork**, part of the guard's package | the agent group's only route to opened hosts and its only name lookups (§3.5) | + +The **core is OS-neutral**: tool schemas, the policy format and checks, the step language, the input ladder logic, +recipes, snapshot and diff format, secret patterns, the log format, pause and wind-down logic, the undo index. The +**per-OS layer** is named in §3, §6 and §13: making the accounts, folder permissions, link-safe file operations, the +service install, snapshots, the accessibility and input back ends, screenshots, refusal checks, and the kill switch. +Windows is built first; nothing Windows-only goes into the core. + +## 2. Upstream fit + +Read at upstream `main` 8cd8a3ca5a (2026-11-13), with open PRs and issues searched the same day. + +**Owner decision (2026-11-13): upstream is used for transport and auth only.** The seeing and acting layer is the +fork's own, written from scratch and speed first (§5). Upstream's computer-use work is listed below so it is clear what +was considered and why it is not the base. + +| Need | Upstream mechanism we use as is | What the fork adds (thin) | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Run T3 on the computer, start at boot | background service: `t3 service install` (Windows Task Scheduler task, macOS login item, Linux systemd user unit; `docs/user/background-service.md`) | an option to install it **for another account** (the host account, §3), since upstream installs it for the signed-in user | +| Reach the computer, auth, revoke | linked environments: pairing code, OAuth session in the secret store, routes over Tailscale/LAN, revoke in Sharing & access (`apps/server/src/peer/PeerLinks.ts`) | a longer link lifetime setting, one-click extension, device binding and revoke-all (§10), in a form upstream could take | +| Send a tool call to the computer | tools whose parameters take `OrchestratorMcpEnvironmentTarget` are forwarded by `McpToolAccess.toLayer` after the caller is checked (`apps/server/src/mcp/McpToolAccess.ts`) | the `host` toolkit, declared like `t3_environment_read` (`apps/server/src/mcp/toolkits/environment/tools.ts`) | +| Limit what a link may do | link access (read-only or a runtime-mode ceiling), `T3-Mode-Limit` on every forwarded call, `linkOrigin` fence below full access | host tools are declared read or write with upstream's access declarations; a read-only link gets only the read tools | +| A full agent working on the computer | `delegate_task` with `environmentId`; the child is an ordinary thread there (`apps/server/src/peer/RemoteDelegation.ts`) | **off in v1** for host-tools computers, with every other forwarded tool outside the host allowlist (§3.2) | +| Settings and their UI | `ServerSettings` / `ServerSettingsPatch` (`packages/contracts/src/settings.ts`), settings pages, settings search | availability and project grants (§4.3), link lifetime (§10), a read view of the policy | +| Keep the tool list small | `McpToolExposure` and `tool_search` (deferred tools); link tools are hidden while the beta is off (merged #30371) | host tools are deferred and hidden unless a linked computer reports host tools on | + +**Upstream computer-use work, considered and not used as the base:** + +- Codex's built-in Computer Use is only _shown_ by T3 (`apps/server/src/provider/CodexToolPresentation.ts:195`, + `packages/shared/src/toolActivity.ts:31`); it runs where Codex runs, not on another computer. +- The Cua Driver stack, open PRs #16527 to #16532 (bundle Cua Driver 0.38 on all three OSes, attach `cua-driver` as an + MCP server to every provider session, settings, a floating preview of the driven window). It drives the host the + provider runs on, one action per tool call, screenshot-led. +- agentcast, open draft #28050: remote desktops over SSH, reads UI trees, background input, refuses on a locked PC + or an elevation prompt, journals every action so a lost reply never runs twice. Its code is in a private repository; + we take **ideas only** (the refusals and the action journal, §6.7 and §7.2), per the fork rule of own + implementations. +- The device toolkit (`apps/server/src/mcp/toolkits/device/tools.ts`) drives phone simulators and emulators, not a + desktop. + +None of these batches steps on the far side, keeps a diffed tree cache, or puts a person's policy between the agent +and the desktop, which is what §5 needs. If upstream later ships a fast, policy-gated desktop layer, we compare and +drop ours where theirs covers it (fork rule: build on upstream where it fits). + +**Custom, and why.** The command broker, the session helper, the engine, the guard service, the snapshots and the +protected store are custom because upstream has no notion of limited OS accounts, of the person's desktop session as a +separate trust zone, or of a batched agent-native desktop layer. Each is an add-on module. Changes to upstream files +are limited to: one registration line per toolkit and setting; the link changes of §10 (lifetime setting, extension, +device binding with a signed body on upstream's DPoP, the linking side taking the current expiry from each reply, +`SessionStore.verify` reading the row's expiry for peer-link sessions); the forwarded-tool allowlist on the +computer (§3.2); the setting that turns T3's terminal and provider features off in the host account's install (§3.2); +and `t3 service install --account` (§3.1). + +**When upstream changes the Linked environments beta:** + +- **The beta becomes default-on or the flag goes away:** our check of `linkedEnvironmentsEnabled` is dropped; nothing + else changes. +- **Forwarding changes shape:** the `host` toolkit is built exactly like upstream's own forwarded tools, so it breaks + where theirs break and takes the same fix. The fork's follow job (nightly) shows it the same day. +- **Linked environments are removed or redesigned without forwarding:** fallback without new transport code: the + agent machine adds the computer's `/mcp` as an outside MCP client (`docs/internals/environment-auth.md`, "MCP clients + are a separate audience"). Same tools, same rules on the computer; only the settings in §4.3 would move. +- **Upstream adds its own remote file or command tools:** we compare and drop ours where theirs cover a tool, keeping + only the OS-account rules, the helper and the snapshots. +- **Upstream ships link renewal or a lifetime setting (#33183):** we drop ours (§10). + +Related upstream work, not merged on 2026-11-13: #36102 (moving work carries its files), #32639 (re-linking revokes the +old session), #35519 (a silent first address costs 10 s per call), #32158 (OpenShell worker fleet), #35826 (read-only +clients get a 403 for write tools), #34293 (host CPU and memory load in `t3_environment_read`; §11 can read it), +#26757 (Tailscale identity sign-in; §10 reuses the idea of checking the caller's tailnet node). + +**Next to the fork's fleet runners (#13).** Separate features. Fleet runners run the fork's own CI jobs on fleet hosts +(root dispatcher, one Linux user per slot, one-job runners, `.github/fork-fleet.json`). Host tools give agents typed +access to a person's own computer. They share Tailscale, the tray (§12) and the activity service (§11), and no other +code or config in v1. A computer that is both a fleet host and a host-tools computer keeps them in **separate +accounts**, and neither account can reach the other's folders; runner accounts on a personal computer get the isolation +of §11. + +## 3. The accounts (per OS) + +Two local user accounts per computer (host and agent), plus one **service identity** (`t3-net`, below) that exists +only with its service. The two user accounts are standard users, never administrators, and have +no password anyone types. +**Neither account ever signs in interactively** (owner decision 2026-11-14): every sign-in right is denied except +"log on as a batch job" (Windows; macOS and Linux: no login shell, no GUI login). The guard starts the broker and the +engine worker with a batch logon (§6.1). The password where the OS needs one is long and random, **set once at install +and kept in the guard's own secret store** (lens m9: readable only by the guard, unlike the protected store, which you +and the host account can read; never shown, never your password, not rotated). The install sets "password never +expires" on both accounts and the verify checks it (correctness minor 3), so a password-age policy cannot lock the +agent out later. If the guard's own store is lost (a +restore of an old system image, a reinstall of the guard), the guard sets a new random password at its next start; +Windows then loses the agent account's DPAPI-protected data (saved browser cookies of the agents' browser), which is +acceptable: the agents' browser signs in again (§6.5). On macOS and Linux no password is needed (the guard starts the +processes as the user directly). + +- **Host account** (for example `t3-host`): runs the computer's T3 server. It owns T3's data (auth database, issued + sessions, link state, its log). It runs no command, script or provider the agent supplies. +- **Agent account** (for example `t3-agent`): runs every command, build and script the agent starts, through the + **command broker**, which runs as this account and accepts requests only from the host account. It cannot read or + write T3's data, T3's secrets or the T3 install. +- **Agent group** (for example `t3-agents`): the agent account and every per-slot user of speculative runs (§7.4). + Every reach-map grant, secret deny entry and auto-run deny entry names this group, never one account, so a slot user + can never read what the agent account cannot (lens N6). +- **Service identity** (correctness r3 major 2): `t3-net` runs the guard's network proxy and resolver (§3.5), so a + parser bug there does not reach the guard. It is a **service-only identity** that the OS creates with its service + and removes with it, so there is no account to find half-made, to + find already there with admin rights, or to forget at uninstall: Windows a virtual service account + (`NT SERVICE\t3-net`, no password, no sign-in right, never in Administrators); Linux `DynamicUser=` in the unit; macOS a + hidden system user with no shell and no home, created and deleted by the install like the agent account (the one + place this needs the account rows of §15). It is not in the agent group. +- The **T3 install** (program files) is owned by an administrator and read-only to all of them. The computer's T3 + holds **no outbound link** to the agent machine, so nothing on the computer can reach back with T3's credentials. + +Why separate host and agent accounts (lens M1): if commands ran as the T3 server's own account, one hostile build script could raise a +link's ceiling, mint sessions, read link credentials, patch T3 to run its code at boot and call the engine with fake +caller details. + +### 3.1 Per OS + +| | Windows (first) | macOS | Linux | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Create the host and agent accounts | local standard users, not in Administrators; for both: deny "log on locally", "log on through Remote Desktop", "access this computer from the network" and "log on as a service"; only "log on as a batch job" stays | standard users, hidden from the login window (`IsHidden`), no admin group, no login shell | system users with a home, no sudo, no login shell | +| The agent's own screen (rung 3, §6.4) | **none in v1** (owner decision 2026-11-14); later options in §6.9 | none in v1 | Xvfb or a headless compositor started as the agent account | +| Run T3 as the host account | a Task Scheduler task at boot, "run whether the user is signed in or not" with **"do not store password" (S4U)** (correctness H2): no stored password to lose or keep in step, and T3 needs no network credentials for its own work (upstream installs "at logon, as me"; the fork adds `--account`). An S4U logon has no password-derived DPAPI key, so the host T3 keeps **no secret under DPAPI or Credential Manager** (its secrets are files in its own folder, ACL'd to it), and the verify checks that; the task has **no execution time limit** (the default 72 hours would stop T3) and restarts on failure (sec n6) | a LaunchDaemon with `UserName` (a login item would only run when that user logs in) | a systemd system unit with `User=` | +| Start the broker as the agent account | the guard, at its own start: a batch logon (`LogonUserW` with `LOGON32_LOGON_BATCH`, the stored password), the broker in a job object the guard owns, on a **window station and desktop the guard made for the agent group** (not the service one, so it reaches no other service's windows and GUI-linked programs start; lens m10, correctness minor 2). **The guard creates the broker's named pipe** and hands it over (lens m8): its ACL admits only the host account, and T3 also checks `GetNamedPipeServerProcessId` is the broker the guard started, so an agent process cannot squat the name | the guard: `posix_spawn` after `setuid`/`setgid` to the agent account; the guard creates the Unix socket (owned by the host account's group, mode 0660) and passes it; T3 checks the peer pid (`LOCAL_PEERPID`) | the guard: a systemd service with `User=`, `PartOf=` the guard; the guard creates the socket the same way; T3 checks `SO_PEERCRED` | +| Reach of the agent group | Modify on the reach map's folders (§4.3 rule 1), inherited, applied as you, the folders' owner (a give, §3.4 audit rule 1; link-safe) | ACL entries (`chmod +a`) by the folders' owner | POSIX ACLs (`setfacl`) for the group | +| Secret and auto-run paths | an explicit Deny ACE for the agent group on each match (§4.3 rules 2-3) | a deny ACL entry for the group | an ACL entry with no rights for the group | +| The agents' own persistence and side starts | Deny ACEs on the agent accounts' own Startup folders and Run keys. **Routes that start a process outside the guard's agent job are denied up front** (sec T1), each with a probe that must fail (§3.3): Task Scheduler (Deny create on every task folder's security descriptor and on `%SystemRoot%\System32\Tasks`, for the agent group), WMI (no Execute Methods or Remote Enable on `root\cimv2` and the other namespaces, so `Win32_Process.Create` fails), DCOM (below), and Secondary Logon (`CreateProcessWithLogonW`, `runas /netonly`, which accepts any network password: an RPC-layer filter, `FWPM_LAYER_RPC_UM`, refuses the agent and runner groups' tokens on its interface, unless the build shows the new process always lands in the caller's job; sec n3). **DCOM (correctness r3 major 1, sec n3):** the machine-wide launch limits are **not** touched, since a Deny there cannot be loosened per AppID and would break WMI queries, CIM cmdlets and build tools. Instead the install computes, for **every** registered AppID, whether its effective launch or activation permission admits a token with the agent group's SIDs (the agent account's batch logon carries `BATCH`, not `INTERACTIVE`, so AppIDs on the default permissions already refuse it; the walk also covers the per-user COM registrations, | +| `HKCU\Software\Classes\AppID` and `CLSID`, of every account it applies to; sec m5), and puts a Deny for the agent and runner groups into the AppID's own launch permission on each one that admits it, except a short allow list measured when built (for example WMI for queries; process creation through it stays refused by the namespace rule). So the effect is an allow list computed from the descriptors, not a hand-written deny list. A registry change notification on the AppID key makes the guard repeat the walk at once (the broker starts nothing new until it is done), and the daily verify repeats it too. A task the guard still finds is deleted, its processes ended, and acting paused | no write to their own `LaunchAgents`; `cron` and `at` denied | `cron.deny`, `at.deny`; no `systemd --user` lingering | +| Disk quota (agent account) | NTFS per-user quota on each volume it can write | APFS has no per-user quota: the agent account's scratch lives on its own APFS volume with a quota (to verify) | `quota` / XFS or ext4 project quota | +| Network | §3.5 | §3.5 | §3.5 | +| Screen, apps, clipboard | none: neither account is in your session. Only through the engine (§6), which admits only the host account | same | same | + +### 3.2 How commands run + +`host_run` asks the broker to start the program as the agent account, with `shell=false` by default and a shell only +when the caller asks for one, in a job (Windows job object / process group / cgroup) that is killed on timeout, on +pause and when T3 stops. The working folder must be inside the reach map. Either way, what a command can touch is what +the agent account can touch. The broker starts nothing (no `host_run`, no `run` step, no speculative run, no engine +worker or agents' browser) while the secret deny pass is not current (§4.3 rule 2). + +**Delegated full agents on the computer are off in v1.** Upstream starts provider processes from the T3 server, so a +delegated agent would run as the host account and could reach T3's data. They are enabled once the fork starts provider +and terminal processes through the broker (one spawn point, to locate in upstream code when built). Host tools need no +provider on the computer. This changes the r2 statement that delegated agents need nothing added. + +**Forwarded tools: an allowlist on the computer (lens N2).** `delegate_task` is not the only upstream tool that starts +work where it is forwarded: thread launch and thread creation start a provider, project create and clone, worktree, +terminal and git tools make the server run processes. On a host-tools computer, the host account's T3 refuses **every** +forwarded tool call outside an exact allowlist: the host tools of §9 (`host_status`, `host_list`, `host_stat`, +`host_read`, `host_search`, `host_write`, `host_mkdir`, `host_move`, `host_delete`, `host_run`, `host_undo`, +`host_observe`, `host_do`, `host_recipe`, `host_ask`, `host_named`) and `t3_environment_read`. A tool upstream adds +later is denied until the list names it. The check runs on the computer, where the call arrives, not on the agent +machine, and on **every** MCP session of that install: peer links and outside MCP clients (the §2 fallback) alike. T3's own terminal and provider features are switched off in that install (a fork setting the install writes; +the host account's T3 starts no provider and no terminal). The install verify (§3.3) checks both. + +### 3.3 Install verify (host tools stay off until it passes) + +The install, and every later start, checks: + +- the host and agent accounts exist and are in no admin or remote group, and hold no sign-in right but "log on as a + batch job" (§3.1); the agent group holds exactly the agent account and the slot users; the install is admin-owned and + read-only to all accounts; **the service identity** `t3-net` is not an admin, holds no sign-in right, is in no + group of ours, has its firewall floor (§3.5) and owns the proxy and resolver ports, and an old install's proxy or + resolver running as the system is gone (sec n4); the broker's, the engine's and the engine worker's channels are as stated (§6.1); no process of the agent + group runs outside a job the guard or the broker owns (the guard ends any it finds and logs it, §6.1); +- the forwarded-tool allowlist is on and T3's terminal and provider features are off in the host account's T3 (§3.2); +- the reach map's grants, the secret deny pass and the auto-run denies are applied and current; the agents' own + persistence points are denied (§3.1); the protected-area watch runs (§4.4); +- **every writable spot in a protected area has its deny entry** (lens P1, owner answer 7 of 2026-11-13): the reach + check below lists them, and the install puts Deny write, delete and create-child entries for the agent group on each + (link-safe, §3.4), with no "accept". A missing entry fails the verify; +- the agent and runner groups cannot start WSL, Hyper-V, Windows Sandbox or BITS jobs (lens M10). Windows: those + services' AppIDs (the WSL service, BITS, Windows Sandbox, the Hyper-V management service; exact AppIDs to verify per + build) are among the AppIDs the computed walk of §3.1 denies, and are never on its measured allow list, so a copied + `wsl.exe` or a direct API call is refused by the service too; the verify re-runs the walk and fails on any AppID + that admits the agent token and is neither denied nor on the allow list; Deny execute on `wsl.exe`, `wslg.exe`, `wslhost.exe` and `hvc.exe` stays as + a second layer. **Ledger, once (correctness r3 major 4):** before the first change to any object the guard sets up + (each AppID descriptor, the Task Scheduler folders, the WMI namespaces, the WebClient start type, §3.5), it writes + that object's original value to the ledger **once** and never overwrites that record, so a second install run or a + re-apply after a Windows update cannot record our own value as "the original". The guard's changes are only **added + entries** (Deny ACEs for the agent, runner and service-identity SIDs), and uninstall removes exactly those entries + and leaves the rest of the current descriptor as it is, so a change Windows or another installer made since stays. + The daily verify re-checks them, since a Windows update can reset them (lens m11); a reset one turns host tools off + until the guard re-applied it (adding its entries again, the ledger record unchanged). **A lost or old ledger** (a + restored system image, sec m6): the added entries are found by their SIDs, which only we use, and are re-added or + removed from that; a start type or setting whose original is lost is left as it is at uninstall and named in the tray; +- the up-front denies of §3.1 hold: a probe as the agent account registers a scheduled task, calls + `Win32_Process.Create`, activates an out-of-process COM server outside the measured allow list and starts a + process through Secondary Logon (`runas /netonly`), and each must fail (sec T1, n3), while the build and test tools + the agent account needs (CIM queries, MSBuild, the language runtimes of the projects) still run (correctness r3 major + 1); the host task is an S4U task with no execution time limit and no DPAPI or Credential Manager secret (§3.1); the + host and agent accounts have "password never expires"; App Control for Business is machine-wide, so it is not used for this. macOS and Linux: no access to + the virtualization frameworks, `/dev/kvm` or the container daemons' sockets; +- the firewall rules exist and each network class of §3.5 is probed from the agent account, including the computer's + own global addresses, an on-link neighbour, a name lookup outside the tailnet, and the paths that system services + carry for a caller (lens M9): a UNC path to an on-link neighbour **and to a public test address** (over TCP 445, an + alternate SMB port and SMB over QUIC, sec S5), a WebDAV URL (`\\host@SSL\x`), adding an IPP printer by a LAN and + an internet URL, a PAC fetch, `WslLaunch` and a BITS job through the COM interface (not only `wsl.exe`). A probe to + an outside address needs no server of ours: it passes when the guard sees the connection attempt dropped (WFP drop + events, `pf` and `nft` counters) and fails when it sees it leave. **Every probe fails closed:** one that reaches its target, or whose result cannot be read, keeps host tools off; +- the disk quota is set; on Linux `fs.protected_hardlinks=1` and `fs.protected_symlinks=1`; +- elevation stays behind a prompt the engine cannot click: Windows UAC on, with the secure desktop; Linux and macOS: + no `NOPASSWD` sudo rule for your account (§4.4); +- runner accounts, if any, have the isolation of §11. + +**UAC "Always notify" by default (owner answer 4, 2026-11-13).** Setup sets UAC to "Always notify". You can lower it in +Windows settings; the verify then shows a warning (below), it does not turn host tools off. + +**Host tools stay off until every check passes**, and `host_status` reports which one failed. `host_status` re-runs +the quick checks on every call and the full set daily; a failure turns host tools off again with the reason. **The +guard runs the deny pass and the verify** (correctness G), at its own start and daily, so after a reboot with nobody +signed in host tools come back on their own once both pass, except after the kill switch or a verify that failed on +more than the guard's own entries it re-applies itself: then the hardened confirm (§4.2) is needed too, and wins. The helper only shows the result. A guard crash mid-pass +leaves host tools off (the pass is "current" only when it completed); the next start runs it again from the top. + +**Warnings that do not block** (shown in the tray and `host_status`, and named in §14): your Windows account is an +administrator with UAC below "Always notify" (known auto-elevation tricks skip the prompt at the default level); on +Linux, your account in the `docker`, `lxd`, `incus` or `libvirt` group, or a polkit rule that keeps admin +authorisation (`auth_admin_keep`) or needs none, since each gives root with no prompt; git +`safe.directory = *` in your git config (git then trusts a `.git` the agent account made); IDE settings that run code +when build files change (IntelliJ and Android Studio auto-reload of Gradle or Maven builds, rust-analyzer build scripts +and proc macros, Visual Studio design-time builds, VS Code `task.allowAutomaticTasks` on or Workspace Trust off), §4.3 +rule 3. + +**Reach check (lens P1, correctness).** Some places are readable or writable by every user by default (Windows: +folders on extra drives, `C:\Users\Public`, `C:\ProgramData`, where Users may create files and folders, the root of +the system drive, where Authenticated Users may create folders, Program Files folders that installers left writable, +HKLM keys that grant Users write; macOS: world-readable shared folders, `/Applications` for admins, a Homebrew prefix; +Linux: world-readable shared folders, `/opt` and `/usr/local` subtrees owned by your account). The install walks every +fixed drive and the registry: + +- **as the agent account**, listing every place it can **read or write** outside the reach map. A writable spot in a + protected area gets the agent-group deny entries above, with no choice to accept it. A readable spot outside the + protected areas you remove or accept; a readable secret-pattern match gets a deny entry (§4.3 rule 2); +- **as your own standard (filtered) token**, listing every protected-area spot your account writes without elevation. + With nobody signed in, the guard gets that token through an S4U logon of your account and loads your registry hive + with `LoadUserProfile`; if either fails, this walk and the HKCU watch wait for your next sign-in, and the tray says + so (correctness minor 10). Until then commands and file tools work, but acting as you and every full-rights grant + stay off, since your persistence points are not yet watched (correctness r3 minor 11). + These join the guard's watch (§4.4), so a change there by an agent-caused process, or during an acting window on a + persistence point, is held. + +The daily check repeats both walks, applies new deny entries, and reports new finds. + +### 3.4 File operations never follow planted links (lens B2) + +The agent account can create symbolic links, junctions and (where the OS lets it) hard links in folders it can write. +Anything that runs as the system, root, the host account or you and touches a path inside a folder that any account +other than the guard can write (the agent account's folders, and also your profile, your persistence points and the +user-writable system spots; the guard's permission re-application, deny entries, snapshot restore, `host_undo`, the helper's copies, sandbox +copies) follows these rules: + +1. It opens each path component without following reparse points or symlinks: Windows `FILE_FLAG_OPEN_REPARSE_POINT` + and a check of the opened handle's attributes; Linux `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | + RESOLVE_NO_MAGICLINKS`; macOS `O_NOFOLLOW_ANY`. +2. It works on the open handle (Windows `SetSecurityInfo` on the handle; `fsetxattr` / `acl_set_fd`), never on a path + it looked up earlier. +3. It refuses a file whose link count is above 1 (a hard link to one of your files). +4. Before it writes or sets an ACL it checks the handle's final path (Windows `GetFinalPathNameByHandle`, `/proc/self/fd` + or `F_GETPATH`) is under the allowed root. +5. Recursive permission application is our own walk that stops at every reparse point and symlink; it never uses an OS + call that follows them. + +**The guard only takes away there (audit rule 1).** On anything another account can write, the guard uses its own +rights only to **take away** (deny, disable, rename into its own folder), on an opened handle whose file identity +(volume and file id, or device and inode) matches its ledger, never by path. Anything that **gives** (an owner, +permissions, restored content, Keep) runs with the rights of the least-privileged account that can write the place +(your account, for your profile, your persistence points and the user-writable system spots), never as an +administrator, SYSTEM or root, even where one of those owns the place; if that account cannot do it, the give is +refused. **The only exception:** the guard undoing its own earlier take-away on the same object, through the checked +handle, when the object's identity still matches the ledger including its generation (the NTFS sequence number, or the +birth time on macOS and Linux, so a reused file id does not match) and its link count is 1; that gives back only what +was there before. "Make it mine" therefore copies `.git` as you, with a walk that follows no links, into a new folder +you own and swaps it in; it never runs a recursive chown as root or SYSTEM. A swapped link therefore gains nothing: the +most it can redirect is a take-away, or a give your own account could make anyway. With nobody signed in, a give as you +uses a token for you (Windows S4U, Linux `setfsuid`); if none can be made, the object stays neutralised, the give waits +for your sign-in, and the tray says so. + +Test (per OS): a planted junction to a system folder, a symlink to your profile and a hard link to one of your files do +not widen any grant, do not redirect any restore or copy, and are reported; a link swapped between the guard's check +and its action gains nothing. + +### 3.5 Network for the agent account (lens M3, N5; owner decision 2026-11-13) + +**Owner decision: no direct internet by default.** Work that needs no computer of yours runs on the server: web search +and fetch already do. The agent account (and the agent group) gets: + +- **Allowed by default:** only the **services you name on your own linked machines** (§10), by their tailnet + addresses and ports: for example the git server and the package cache you run there (correctness review). The list + is config per linked machine, default empty; the install offers the services it finds listening there, one tick + each. Each named service is **its own allow rule (one address, one port) for the agent group's direct connections + only**; it is not a hole in the floor below, so the proxy and an opened "public internet" never reach a linked + machine (sec S4). A whole machine is never opened, so a hostile build script cannot reach the agent machine's T3, SSH, + databases or dev servers. Worked example below. +- **Opened per goal:** a goal that needs more (a package registry, a git host, "the public internet") asks once through + the batched ask (§4.2), default scope **this task**. The ask names the hosts; "the public internet" is one item of + its own. **Named hosts go through the guard's proxy** (correctness F): the broker sets `HTTPS_PROXY`, `HTTP_PROXY` + and `ALL_PROXY` for agent commands to a CONNECT proxy the guard runs on loopback. The proxy **accepts only `CONNECT`** + (a plain-HTTP request in absolute form is refused, so there is no second parser path), and admits a connection only + when the CONNECT host and the TLS SNI both match a host the goal opened, resolves the name itself, and **applies the + floor below to every resolved address** before it connects (sec P1, correctness P): an answer in an always-denied + class (loopback, LAN, link-local and cloud metadata, tailnet, this computer's own addresses, an on-link prefix) is + refused and logged, whatever name it came from. It connects to the address it checked (pinned for that connection), + and checks again on every new connection, so a DNS answer that changes between the ask and the connect (rebinding) + meets the same check. The proxy and the resolver **run as their own low-rights service identity** (`t3-net`, §3, + lens m13), not as the system, and that identity's outbound sockets sit under the same floor in the firewall, so a + bug in the address check still cannot reach the floor's classes. **One named exception** (correctness r3 major 2): + `t3-net` may reach the DNS servers the system is configured with (often the router, a loopback stub or the tailnet's + resolver), **on port 53 only**; the guard re-reads that list on every address change, like the floor. So the + resolver works on any network, and the most a bug there gives is DNS queries to a server you already use. So a CDN host works whatever address the system resolver + would have given. Other sites on the same CDN are closed **only as far as the CONNECT host and SNI show**: a client + that sends a different `Host` inside the TLS stream (domain fronting) reaches another site on that CDN, the same + limit as the DNS residual below (lens m12, §14). A program that ignores the proxy settings cannot reach the host (it + fails, nothing opens). **git over SSH** does not use these proxy settings, so for named hosts only HTTPS git works in + v1 (correctness minor 5); SSH to a git server on a linked machine works as a named service. "The public internet" is + the one item that opens direct connections (the floor below still applies). +- **Name lookups (lens P3, correctness).** The agent group gets a resolver run by the guard. It answers only the names + of your linked machines and the hosts a goal opened, and refuses every other name, so a lookup of + `.attacker.example` never leaves the computer through it (the tailnet's own resolver forwards outside names to + public resolvers, so it is not used). Linux: the command's namespace sees only that resolver, and the system + resolver's sockets (`systemd-resolved`'s varlink socket, `nscd`) are hidden in its mount namespace. **Windows and + macOS:** programs resolve through the DNS Client service or `mDNSResponder`, which do not run as the agent account, + so firewall rules by account do not see those lookups; the guard's resolver serves only programs that ask it + directly. There, DNS tunnelling through the system resolver is **a working two-way channel** (lens M9), so "no direct + internet" does not hold against a determined agent on Windows and macOS (named in §14). Mechanisms to verify that + would close it: Windows, a WFP filter at the RPC layer (`FWPM_LAYER_RPC_UM`) that refuses the agent group's token on + the DNS Client's RPC interface; macOS, a sandbox profile for agent commands that denies `mach-lookup` to the + `mDNSResponder` services. Linux also hides the system D-Bus socket in the command's mount namespace, so services that + fetch on request (PackageKit, fwupd) cannot be asked to. +- **Browsing as you** (your logins, your sites) happens in your own browser session through the UI, which is acting as + you (§4.4), not through the agent account. + +**Worked example (owner answer 4, 2026-11-14).** You link your test VM. Its config names two services: "the VM, port +25565 (test server)" and "the git server, port 22". Agent commands on this computer can now open those two ports and +nothing else there: not the VM's SSH admin port, not its hypervisor panel, not a database on it, not the agent +machine's T3. A goal that needs more, for example "restart the test server through its admin panel", asks once, +naming that one service; your answer has the scope you pick (§4.2). Removing a service from the config closes it at +once (the guard updates the agent group's rules before the next agent connection). + +**Always denied, even when the internet is opened** (the floor no answer removes, except an explicit entry for one +address): loopback (`127.0.0.0/8`, `::1`), RFC 1918, CGNAT and tailnet (`100.64.0.0/10`, `fd7a:115c:a1e0::/48`), **with no +carve-out for your linked machines** (their named services are separate allow rules, above; sec S4), link-local (`169.254.0.0/16`, `fe80::/10`, which includes cloud metadata +`169.254.169.254`), ULA (`fc00::/7`), multicast and broadcast, **every address of every local interface** in both +families (so your own services listening on all addresses are not reachable through the computer's global IPv6 +address), and **every on-link prefix** (so a NAS or router with a global IPv6 address on your LAN is not "the +internet"). The guard re-reads the interface addresses and prefixes on every address change (Windows +`NotifyIpInterfaceChange`, macOS `SCDynamicStore`, Linux rtnetlink) and updates the rules before the next agent +connection; while an update is pending, the agent account's network is closed. + +- **Loopback:** the agent account may reach only listeners of its own (tests that start a local server), plus two + named exceptions: the guard's resolver and the guard's proxy, each on a fixed port owned by the guard's `t3-net` + identity (the guard checks the listener's owner before it adds the exception, so an agent process cannot squat the + port). Linux: each command runs in its own network namespace with its own loopback and a user-mode route out (`pasta` + with the host-loopback mapping off, `--no-map-gw`, DNS forwarded only to the guard's resolver, and exactly two + forwards from the namespace's loopback to the host's: `-T ` for the proxy and `-U ` for + the resolver; correctness minor 4); macOS: `pf` `user` rules + on `lo0` (to verify); Windows: a WFP filter on loopback that matches the connecting and the listening user (to + verify). It never reaches your dev servers, a browser's debug port, a desktop T3, container APIs or sync UIs. +- **Namespaces set up by the guard (correctness).** On Linux the guard (root) makes each command's mount and network + namespaces and then starts the command as the agent account's own uid, with no capabilities and no user namespace of + its own, so it cannot unmount the read-only binds of §4.3 rule 3 or change its network. **The guard starts `pasta` + as root with `--runas` set to the agent account's uid and gid**, pointed at the command's network namespace, so + `pasta` joins the namespace and sets up its side before it drops to the agent account (correctness r3 minor 12). If + the build shows it needs a capability after the drop, it keeps only `CAP_NET_ADMIN` (ambient), nothing else. On the + host side its sockets are the agent account's, so the `nftables` `skuid` floor applies to them too; §16 checks that + a command's namespace reaches the proxy and the resolver and nothing else. +- **Per OS:** Windows: raw WFP filters in the fork's own sublayer, with weights, scoped to the agent group's SIDs + (ordinary Windows Firewall rules cannot express "block all except" because a block always beats an allow there); + macOS `pf` with `user`; Linux `nftables` `meta skuid` / `meta skgid`. **The base block is always on** (correctness + minor, lens n4): Windows persistent and boot-time WFP filters; macOS a `pf` anchor loaded at boot by a LaunchDaemon + that runs before the guard; Linux `nftables` rules in a unit ordered before the guard and the broker. Only the + openings are dynamic, so a guard crash, or a boot before the guard starts, leaves the agent group's network closed. +- **Openings never outlive their grant (correctness).** A per-goal opening and a one-address exception are firewall + state. The guard writes a ledger line before it adds the rule, and removes the rule when the grant ends, on pause and + on the kill switch. Windows uses WFP dynamic-session filters, which vanish with the guard's session; on every start + the guard removes every opening in its ledger **before** the agent group's network comes up, so a crash or power + cut cannot leave the network open. Order at start: (1) the base block is already in place (above); (2) remove every + ledgered opening; (3) re-apply the "this project" and "always" exceptions from the protected store, each with a new + ledger line; (4) only then start the broker. A crash during (3) repeats (2) and (3) at the next start. +- **No way around the floor through system services or VMs (lens P3).** WSL and Hyper-V traffic leaves through their + own NAT, not as the agent account, so the agent and runner groups may not start them (§3.3, DCOM denies). BITS + transfers run in a service and are denied the same way. **Windows services that open paths for a caller (lens M9):** + a UNC path is opened by the SMB redirector in the System process, `\\host@SSL\x` by the WebClient service, an IPP + printer URL by the print spooler, a PAC URL by the WinHTTP auto-proxy service. None of these runs as the agent + account, so the per-SID floor does not see them. **Owner decision 2026-11-14 (§17):** the install turns + the **WebClient service (WebDAV) off for the whole computer**, since nothing per account can stop it: it writes the + old start type to the ledger once (§3.3), sets the start type to Disabled and **stops the running service** (sec n7); + the daily verify re-checks the start type and re-runs the WebDAV probe; uninstall puts the old start type back only + if the start type is still the one the guard set (a later change of yours stays). **SMB and printers to the + internet: one computer-wide block (sec S5; pending owner, issue #29, written with the recommended default).** Rules by the + caller's account cannot see SMB, since the redirector runs in the System process. So the install adds WFP filters + for the whole computer: the **System process and the print spooler** get no outbound connection to any address + outside the local classes (loopback, your LAN prefixes, link-local, tailnet), on any port, TCP or UDP, which covers + SMB on 445, on alternate ports and over QUIC; where the build shows SMB over QUIC is not covered by that filter, the + install turns the SMB-over-QUIC client off (ledgered once like WebClient). Your home network, NAS and printers are + unaffected; what stops working is SMB or a printer on an internet address, for every account on the computer (the + owner's view says so). For the spooler and the WinHTTP auto-proxy on your LAN, per-account candidates to verify in + the build: commands in an AppContainer without network capabilities (§7.4 already uses one) and an RPC-layer filter + by caller token (`FWPM_LAYER_RPC_UM` on the spooler's and the WinHTTP auto-proxy service's interfaces). Where no + per-account answer holds, the residual is said in §14: an agent command can open SMB paths and add printers **on + your LAN and tailnet** (data can leave that way; the credentials sent are the agent account's own, a random password used + nowhere else). Until the computer-wide block holds (probed, §3.3), the residual is "your LAN and tailnet **and the internet**", + and host tools stay off. The verify probes each path (§3.3) and **fails closed**: a computer where one gets through + keeps host tools off, with the reason in `host_status`, unless you accept that one residual by name (hardened + confirm), which is then shown in the tray; the internet SMB path cannot be accepted this way. +- The computer's T3 gives loopback callers **no** trust or bootstrap: other local accounts are remote clients that must + sign in. +- The install verify (§3.3) probes one address of each class from the agent account, plus the computer's own global + IPv6 address, an on-link neighbour, a port on a linked machine that config does not name, a lookup of a name outside + the tailnet, a host the goal did not open through the proxy, and the service paths of §3.3; and, with a test goal + open, an opened name that resolves to a linked machine's tailnet address (through the proxy) and, with the public + internet opened for the probe, a port on a linked machine that config does not name (sec S4), and refuses to enable + host tools if any gets through (on Windows and macOS the lookup probe checks the guard's resolver only, as above). + +## 4. Scope and rules + +### 4.1 Goal-driven scope (owner decision 2026-11-13) + +You say what you want done. The agent picks the route (§5). It may use the **whole computer except sensitive things**, +and it looks only at what is relevant to the task. + +**Open by default:** + +- Your data folders (the reach map, §4.3 rule 1): read and write, with undo. +- System areas: **read** as far as the OS lets any standard user, except secrets. Writing, deleting and running there + is a protected-area action (§4.4). +- Every app and window, for seeing and acting, through the routes of §5 and the input ladder of §6.4. Acting there is + acting **as you** (§4.4). +- The clipboard (with the limits of §6.7). + +**The sensitive set: never usable, no answer can open it** (hard refusals): + +- Secret files by built-in pattern (keys, `.env`, `*.pem`, `*.kdbx`, cloud and CLI credential files, …) and every + secret store: OS keychains and credential managers, browser login and cookie stores, password managers, wallets. + System secrets too (the SAM and SECURITY hives, `/etc/shadow`, SSH host keys), which the OS already denies. +- Password and credential fields and dialogs, the lock screen and the secure desktop, elevation prompts. +- The tray, the helper, the ask dialog, T3's host-tools settings and the protected store (§4.3 rule 5). + +**Relevance.** The agent names the windows and folders a step needs. The window list gives ids, apps and titles (a +title that matches a secret pattern is redacted). Trees and screenshots come only for named windows; other windows are +blacked out of a crop. A whole-screen capture needs a reason in the batch and is logged. Searches run in the folders +the task names, not across every drive. + +**More specific: the narrow high-risk list** below needs the batched ask (§4.2). + +### 4.2 The one batched ask + +When the agent's plan needs anything from this list, it asks once for all of it: `host_ask {items, reason}`. The +helper shows one dialog: **"Can I use X, Y and Z?"**, each item with what it is and why it is on the list, a scope +(**this task** / **this project** / **always**) and a tick box; then Allow and Deny. Facts come first, the agent's +reason last and marked unverified; Deny is the default; one ask is open at a time; unanswered after 2 minutes it is +denied. + +| On the high-risk list | Why | Default scope | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------ | +| **Full-user-rights apps** for acting: shells and terminals (also those built into IDEs, and IDE task runners), the Run dialog, the Start/Search box, Spotlight and other launchers, the file manager, system settings, the task scheduler, the registry editor, script hosts and macro consoles, browser devtools | acting there runs any code as you directly (other apps can too, through a file dialog or a link; §4.4) | this task | +| **Privilege elevation:** a command run as admin, a machine-wide install | changes the OS for everyone | once (one command), never remembered | +| **A protected-area action** (§4.4): one exact write, delete or run in a system area, another user's profile or an autostart or persistence point | system-wide or lasting effects | once, never remembered | +| Installs for you (winget / Homebrew / the distro's package manager) | new code that runs as you | this task | +| Your profile's config and app-data areas (`AppData`, `~/Library`, `~/.config`, dot-folders) beyond the reach map | they hold tokens and sessions outside the built-in patterns | this project | +| Auto-run paths in a folder (git hooks and config, IDE task files, mods and plug-in folders; §4.3 rule 3) | code there runs as you without you choosing to run it | this project, per folder | +| Other users' data on the computer | not yours to give | this task | +| A new app-side bridge, with its verbs listed (§6.6) | a bridge acts inside an app with your rights | always | +| Network for the agent account beyond the named services on your linked machines (§3.5): named hosts, or the public internet | data can leave; downloads can bring code in | this task (per goal) | +| An address in an always-denied class (§3.5), one address at a time | your LAN and tailnet services | this project | + +**Remembered.** Answers live in the protected store, per scope: the agent never asks twice for what you already +answered in that scope. A "this task" grant ends with the task (the T3 turn chain that asked), on pause, and on the +kill switch. + +**Not worn down (lens m4).** After a Deny, the same item is not shown again in that task; a new ask with only items +denied in the last 10 minutes is dropped without a dialog; the dialog shows how many asks this task already made. An +ask that times out unanswered is denied for that call only: it does not count as a Deny for these rules, so one missed +dialog does not block the item for the rest of the task. + +**Confirmed where the agent cannot click (owner decision 2026-11-14: a T3 confirm, no OS prompt).** The engine +refuses to see or act on the ask dialog. But code with your rights may already run (a full-rights grant, or a file run +through an app's UI, §4.4) and could click a dialog in your session or call the helper. So **Continue** after a +take-over and the widening answers below are taken only through the **T3 confirm**: a confirm the agent cannot fake, +shown in T3's style, with no Windows, macOS or Linux prompt. The rare high-risk actions take the **hardened +confirm** instead (below): the OS's own admin prompt. The rule "widening after recent acting needs the T3 confirm" +stays: whenever an acting batch or a full-rights grant ran in the last 30 minutes (setting), not only while a marked +process is still alive, since a process can escape the mark (lens P5). Narrowing never needs it. + +**The T3 confirm (owner answer 3, 2026-11-14).** On this computer it is the **confirm panel** (§1): a small native +window the guard starts in your desktop session, drawn like T3, holding no web content. It is unforgeable by these +rules together: + +1. **Out of reach of your own processes, with few rights itself.** Windows: the guard starts it in your session with a + system-integrity token from which every privilege is removed and whose rights are restricted (lens m4; to verify + that UIPI still shields it with that token), so processes running as you (medium integrity) cannot send it window + messages, inject input into it or open its process. macOS: a signed binary with the hardened runtime and library + validation, started by the guard as a LaunchAgent in your GUI session; the guard checks the connecting process's + code signature through its audit token. Linux: a process the guard starts as you; its window is not trusted, since + the input check (4) does not depend on it. **X11 (lens m1):** any of your X clients can draw over or change the + panel window, so on X11 it cannot show you which text you confirm. **On X11, v1 has no on-screen acting and no + high-risk OK** (those items are refused); Wayland works. **The panel is + excluded from screen capture** (sec S1): Windows `WDA_EXCLUDEFROMCAPTURE`, macOS a window `sharingType` of none (to + verify on current macOS), Wayland by design. +2. **Bound to one item, once (closes lens M8, sec T6a, correctness R).** The guard makes each pending item from your + click in T3 or the tray, or from the agent's ask, and gives it a **fresh random nonce** (128 bits) and an expiry (2 + minutes, counted from when the panel first shows it, so an item that waited in the queue is not dead on arrival; + correctness r3 minor 4; an item not shown within 10 minutes is dropped). The panel asks the guard for it and shows + its exact text and a short code made from the nonce, the text and the computer's id. Every answer carries the nonce; the guard writes the answer + only if the nonce is still pending and the text hash matches, then marks the nonce used. Two "Continue" items + therefore never share a code, and a captured answer is worthless the moment it was used or expired. The item never + comes from a command line or from the helper's word. The text is shown safely (lens m4): control and bidi + characters escaped, host names in punycode, length capped, the full text one click away. +3. **One item per kind, rate-limited (lens m5, correctness minor 7).** The kinds are: Continue, an ask answer, a + hardened action, a setting change. A new item of the same kind from T3 or the tray replaces the pending one (the old + nonce dies); a replaced item **re-arms the 1-second delay of (6) and shows "this request changed"** (sec S1). + Requests from T3 or the tray count as "from your session", **not as proof of a person**, since code running as you + can drive them; only the confirm itself proves a person. Items of different kinds queue and are shown one at a time. + The agent's own asks are rate-limited per task (setting, default 3 in 10 minutes) **and per computer across all + tasks** (setting, default 6 in 10 minutes; sec n5); beyond that the tray shows "too many requests from agents" and + further asks are dropped without a panel, without cancelling your own pending item. A request from a source that is + neither (no T3 UI session, not the tray) that tries to replace a pending item raises an alert. +4. **Only the panel's own raw input counts (sec T5, correctness K).** The panel never decides from a control being + activated. It draws its own buttons (no system button controls), exposes **no accessibility actions** (Windows: no + UI Automation Invoke or other control pattern and no MSAA default action on the buttons; macOS: no `AXPress`; + Linux: no AT-SPI `DoAction`), and ignores window messages that would press something (`BM_CLICK`, `WM_COMMAND`, + keyboard messages it did not see in its hook). A decision comes only from an input event its own low-level hook + saw, located by the event's raw coordinates (not the system cursor image, which code running as you can change; lens + m7) and inside the button: Windows, events without `LLMHF_INJECTED` / `LLKHF_INJECTED` (and without the + lower-integrity flags), which also refuses `SendInput` from a `uiAccess` process; macOS, only events whose source is + the hardware (event source pid 0, HID system state; to verify on current macOS); Linux, the panel shows a 3-letter + code and the guard (root) reads the physical keyboards' `evdev` devices, excluding every `uinput` device, so XTest, + portal and `ydotool` input never count. **While the panel is open the guard grabs those keyboards (`EVIOCGRAB`)**, + so the keys reach no other app and no app sees them (correctness r3 minor 5; the panel says "keyboard held for this + confirm, Esc denies"); the answer is the code followed by Enter with no other key in between, and any other key + starts it over (sec S1). On Windows and macOS the ordinary confirm is a click only; no code is typed there. A §16 test drives the + panel with each injection route (`SendInput`, UI Automation Invoke, `BM_CLICK` and other window messages, `AXPress`, + a synthetic `CGEvent`, XTest, AT-SPI `DoAction`, `uinput`) and none of them answers it. +5. **Nothing else acts while it is open.** The guard stops the engine (no batch step runs) and the panel drops every + injected event system-wide while it is open (Windows: its low-level hooks swallow them; macOS: an event tap, which + needs the Accessibility permission you grant once at setup; Linux: the engine stop, plus the code check). +6. **No click-jacking, no caught or steered clicks (lens m6, sec S1).** Deny is the default. The panel opens away + from the pointer and does not take focus by itself; it refuses a click while any other window overlaps it, and its + buttons become active only after it was shown, uncovered, for about 1 second. A click counts only when **no cursor + clip is active** (Windows `GetClipCursor` equals the whole virtual screen) and the pointer **reached the button by + hardware move events** the hook saw and rested there about 300 ms; when the system pointer position differs from + the one tracked from hardware moves (a `SetCursorPos` or `CGWarpMouseCursorPosition` warp), the click is refused and + the arming starts over. + +**Hardened confirm for the rare actions: the OS's own admin prompt (pending owner, issue #28, written with the +recommended default).** A full-user-rights grant, Allow on an elevation or protected-area item, Keep of a held +protected-area change or a quarantined auto-run path, "make it mine", turning host tools back on after a failed verify +or the kill switch, and confirming an outside edit of the store take the **hardened confirm**: always (not only after +recent acting), and only through the OS's own admin prompt on its protected surface: on Windows the fork's signed +confirm binary through UAC on the secure desktop (as for an elevated command, below), on macOS the authorization +dialog, on Linux polkit (Wayland only, and only where the build proves its agent cannot be replaced; refused on X11, +§4.2 rule 1). On Windows and macOS, code running as you cannot draw on or click that surface (sec S1), which removes the +screen and input tricks for these actions rather than adding rules against each. **On Linux the polkit +authentication agent is a process in your session that runs as you,** so code running as you can end it and register +its own look-alike: it is not a protected surface. It counts as the hardened confirm only where the build proves the +session's agent cannot be replaced by code running as you (for example one built into the compositor); elsewhere +these items are refused, as on X11 (§14). **Where the hardened confirm is refused** (X11, or such a Linux desktop), +host tools come back after the kill switch or a failed verify, and an outside edit of the store is confirmed, only by +re-running the admin install. +A confirm from a phone or a second computer is deferred to its own design (§17). The OS's own prompt also stays where +the OS itself demands it: an elevated command still +shows UAC (Windows, on the secure desktop), `sudo` or polkit (Linux), or the authorization dialog (macOS) when it runs, +after the hardened confirm allowed that exact command. + +**Nothing running as you changes who confirms (audit rule 2).** Nothing that code running as you can do (remove, +reset or re-register a device, a key or a setting) may change who or what confirms a high-risk action. In v1 that is +fixed to the OS's own admin prompt; only the admin-run install can change it. + +**Residual risks, said openly.** The ordinary confirm on this computer's own screen (Continue and ordinary widening) +rests on the checks of rules 1 to 6; a way around one of them is limited to those items, never a hardened one. +Input from remote-control tools (VNC and similar, and Remote Desktop into your own +session until verified otherwise) and from an on-screen keyboard arrives as injected, so the panel refuses it: Continue then +waits until you are at the computer (the remote confirm is deferred, §17). On macOS the hardware check of (4) is to +verify; if it does not hold, the panel uses the Linux code method with the guard reading input through an IOHID +listener. + +**Every tray and T3 action is classed (lens P5).** Widening actions are written only by the guard, never by the helper +or T3 alone: + +| Action | Class | What the guard checks | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------ | +| Deny, pause, wind down, kill switch, revoke, revoke all, Remove of a held or quarantined item, End of a process, removing a reach-map folder, runners off | narrowing | nothing | +| **Continue** after a take-over (tray, T3, or Allow on the agent's ask item "continue on your screen") | widening | the T3 confirm, always | +| Allow on a full-user-rights item; Allow on an elevation or protected-area item; Keep of a held protected-area change or a quarantined auto-run path; "make it mine"; host tools back on after a failed verify or the kill switch; confirming an outside edit of the store | widening | the hardened confirm (the OS admin prompt), always; an elevated command then also shows the OS prompt when it runs | +| Resume after pause or wind-down, Allow on other ask items, accepting a reach-check find, adding a reach-map folder, extending links, every other setting that widens | widening | the T3 confirm when an acting batch or a full-rights grant ran in the last 30 minutes | + +**Continue and widening come only from a person.** The computer accepts Continue and every other widening from T3 only +through a person's T3 UI session on that computer (the confirm panel) or the OS admin prompt, never through an MCP +or agent session. The engine refuses to see or act on any window that shows a T3 page with host-tools controls, +including Continue banners, the ask and the confirm panel (§6.7). + +**The guard checks the confirm itself (lens N4).** The helper runs as you, so code running as you could inject into it +or call the guard's channel and claim "the owner agreed". The guard therefore never takes the helper's word for an item +that needs the T3 confirm or an OS consent; it writes the answer only after it verified it itself: + +- T3 confirm: the answer comes from the panel the guard started (Windows: the guard holds the panel's process handle + and its token is system integrity; macOS: audit token and code signature; Linux: the guard read the code itself), + over the pending item's nonce and text hash (rule 2). +- The hardened confirm and the OS consent for an elevated command, Windows: the fork's signed confirm binary, started through UAC on the secure desktop, connecting with a full (elevated) + high-integrity token. The guard checks the token's elevation type and integrity, the image path and its signature, + and that the binary showed the pending item's code; the binary asks the guard for the item and shows its text in its + own elevated window, never from its command line. If your Windows account is a standard user, the prompt asks for an + administrator's name and password each time (lens n7). +- OS consent, macOS: a root LaunchDaemon cannot show `SecurityAgent` itself, so the helper obtains the guard's right + for this one item (one right per pending item, with that item's text) with `AuthorizationCopyRights` + (`authenticate-session-owner`, `shared = false`, `timeout = 0`) and passes it as `AuthorizationExternalForm`; the + guard verifies that exact right (lens p4, M8). +- OS consent, Linux: the guard itself makes the polkit check for its own action (`auth_self`, not kept), with the + helper's process as subject and the item text in the details (what the polkit agent shows of the details is to + verify per desktop). On X11, code running as you can read what you type into the polkit agent, including your + password; on Wayland it cannot. On either, code running as you can replace the polkit agent itself, so it counts for + the hardened confirm only where the build proves that cannot happen (above). Named in §14; Wayland is recommended. + +Other answers (a dot-folder, a network host) come from the helper as before when nothing acted in the last 30 minutes. +Code running as you could forge those; they grant the agent account only things your own account already has, but a +forged "always" answer would outlive the code. That is why widening needs the T3 confirm after recent acting, and why +every remembered answer is listed in the tray with its time and the check it carried. + +**State.** Pending items and their nonces live in the guard's memory only: a guard crash or reboot drops them, the +panel closes, nothing is written, and an answer that arrives later for a dropped nonce is refused (the person asks +again). Used nonces are kept until their expiry passes, so a replay inside the 2 minutes is refused too. The panel and +the engine stop are released on answer, on timeout (2 minutes) and on guard exit, so a crash cannot leave acting +stopped for good: the engine treats a lost guard channel as pause (§6.7). + +### 4.3 Rules the computer enforces + +Normal work needs no click. Each rule says **who enforces it**, because a command run as the agent account can change +anything that account owns. Nothing that must hold is enforced only by the T3 server. + +1. **Reach map** (OS). The agent group gets read-write on your data folders. **Default (owner decision 2026-11-13):** + documents, desktop, downloads, media (pictures, music, videos), code folders and the data drives. The list is + config: it grows over time (the tray and settings add folders; the install suggests code folders it finds), and + the core holds only the default kinds, never a person's paths. Your profile root, its config and app-data areas and + other profiles are not granted (they are on the ask list); system folders are readable as the OS allows and are + protected areas (§4.4). The guard applies the grants at its start and re-applies them daily (§3.4 rules), and + reports drift. A data drive in the reach map is granted below its root; the root-folder deny of §4.4 applies to the + root folder itself only, not to what the grant covers (correctness minor). Removing a folder ends every agent-account process with an open handle under it + (Windows Restart Manager, `lsof`/`/proc`), because an open handle keeps its access after an ACL change. +2. **Secret deny list** (OS, applied by the guard). Built-in patterns, one OS-neutral list in the core; + the policy can add patterns, never remove a built-in one. Every match inside the reach map gets a deny entry, and so + does every match the reach check (§3.3) finds readable outside it, including app tokens in `ProgramData` and configs + in `/etc` (the guard adds a deny entry for the agent group only, which changes nobody else's access; where it cannot, + the match is named in `host_status` and §14, lens p9). A matching file you create is covered at once: on macOS and + Linux the guard decides every agent-group open of a secret-pattern name itself (Endpoint Security `AUTH_OPEN`, + `fanotify` `FAN_OPEN_PERM`, as for protected areas, §4.4), so there is no race; on Windows a watcher denies it within + about a second and then ends every agent-group process holding a handle to it (Restart Manager, as in rule 1), since + a handle keeps its access (sec n2; the read in that second is named in §14). **Fail closed:** on guard start and + after a watcher overflow, a full deny pass runs **before** host tools are enabled. The daily re-apply only adds + entries; it runs while the last pass stays current and freezes nothing (correctness r3 minor 3). While the pass is not current, the server refuses file reads **and** the + broker starts no agent-account process: no `host_run`, no `run` step, no speculative run, no engine worker and no + agents' browser (lens M8b, correctness review), since those read through the OS, not through the file tools. + **Running processes are stopped too (sec T2, correctness S, sec n1):** while the pass is not current, no + agent-group process runs. Windows: the guard first caps the job's active-process count at its current number + (`JOB_OBJECT_LIMIT_ACTIVE_PROCESS`), so no new child can start, then suspends every member and checks again until + all are suspended; the job allows no breakaway (`BREAKAWAY_OK` and `SILENT_BREAKAWAY_OK` off). Linux: the agent + cgroup's freezer, which takes the whole group at once. macOS has no group freeze (a process forked during a + `SIGSTOP` walk can wake its siblings), so there the processes are **ended**, not frozen. Any agent-group process + found outside the job or cgroup is ended. They are thawed only when the pass completed. A watcher overflow makes the + pass stale at once, so flooding the watcher on purpose **stops all agent work on this computer** (every task and the + engine worker; macOS ends it): the cost falls on agents, not on you. A guard restart counts as stale until the pass + ran again from the top (§3.3), and at start the guard **ends every agent-group process left from before**, frozen + or not (a Linux cgroup stays frozen after a guard crash; correctness r3 minor 3). Token shapes (`ghp_`, `sk-ant-`, `AKIA…`, JWTs, …) are + redacted in every output path and every stored record (§6.7). +3. **Auto-run paths are read-only to the agent** (OS deny entries and the guard, lens B1e, N3). Inside writable + folders: `.git/config`, `.git/hooks/`, `.git/info/` (`.gitattributes` filter lines are left alone, since the + filters themselves live in `.git/config`); IDE task and settings files (`.vscode/`, `.idea/`); `.envrc`; and the + mod and plug-in folders of apps you run. Code there runs as you when you merely open or `git status` the folder. + **`.git/config` stays read-only until you unlock that repo** through the ask (owner decision 2026-11-13); git + commands that write repo config (`push -u`, `remote add`) fail for the agent until then, and the tool descriptions + say so. Autostart places outside a project (shell profiles, Startup folders, login items) are protected areas + (§4.4). + - **No rename or delete around the deny** (N3a). Windows: Deny `DELETE` on each auto-run file and folder, and Deny + `FILE_DELETE_CHILD` on the folder that holds it, for the agent group (Windows allows a rename or delete if either + right is there, so both are denied; other entries in that folder stay deletable through their own `DELETE`). + macOS: Endpoint Security `AUTH_RENAME`, `AUTH_UNLINK` and `AUTH_CREATE` deny the agent group on auto-run names. + Linux: each command runs in its own mount namespace (next to the network namespace of §3.5), and every existing + auto-run path is bind-mounted read-only into it; a mount point cannot be renamed or removed. + - **New auto-run paths are quarantined** (N3b). Where the agent group **creates** an auto-run path (a new + `.vscode/tasks.json` in a folder that had none, an `.envrc`, a new plug-in in a mod folder), it is blocked where the + OS can block (macOS Endpoint Security; Linux a BPF LSM where the kernel has one), otherwise the guard moves it into + the undo store within about a second (writer recorded, §3.4 link-safe) and lists it in the tray and the next ask, + with its content, as "keep this?". A live watcher alone is not relied on: the quarantine happens whether or not you + are looking. + - **New repositories** (`git init`, `git clone` by the agent): **only `.git` is exempt** (lens P6). It is owned by the + agent account, and your git refuses to use it (git's `safe.directory` owner check) until you take it over with + "make it mine" in the tray, which hands `.git` to you and applies the denies above. Every other auto-run path in + the new repository (a cloned `.vscode/tasks.json` with `runOn: folderOpen`, `.idea` run configurations, `.envrc`, + plug-in files) is quarantined like any other new auto-run path, and "make it mine" lists them in the same ask, with + their content. With `safe.directory = *` in your git config (warned in §3.3), new `.git` folders are quarantined + too. + - **Package manifests and build files stay writable** (`package.json`, `build.gradle(.kts)`, `build.rs`, `.csproj`, + …): agent work is code. They run when you choose to build, **and also when an IDE syncs them on its own** + (IntelliJ and Android Studio Gradle or Maven reload, rust-analyzer build scripts and proc macros, Visual Studio + design-time builds). The install verify warns about these settings (§3.3); every agent write to a build file is + flagged in the activity log (rule 7); the residual is in §14.1 (N3d). +4. **Approved runs use frozen copies** (engine and helper, lens B1). This rule covers what T3 runs as you on your + behalf; running files through the UI is open (§4.4). + - Every launch, approved script, named command and admin command runs in a working folder the agent account cannot + write (so no planted DLL is picked up). + - Approved scripts, named commands and admin commands may not run files from agent-writable folders. A command that + needs one (a deploy from a repo) runs from a **frozen copy**: the helper copies the referenced files, or makes a + verified git copy at a commit, into a protected folder; the ask shows the hash or commit **and the diff since the + last commit you approved** (lens n6); the copy runs. + - **A frozen git copy is built only from verified objects (lens P2).** The agent can write `.git/objects`, packs and + `objects/info/alternates`, and git does not re-hash objects on normal reads, so a commit id alone proves nothing. + The helper clones into the protected folder with `git -c transfer.fsckObjects=true clone --no-local --no-hardlinks` + and no alternates, then checks out the commit, so every object is re-hashed on the way in. Git runs there with the + source repo's config, hooks, attribute filters and alternates ignored (a fixed system and global config of its + own, `core.hooksPath` empty). "The same hash later needs no new ask" reuses the **kept, verified copy**; a missing + or changed copy is built and verified again. + - A bridge whose file is in an agent-writable folder must be listed with its hash (§6.6). + - The engine's own COM automation (Office and similar) creates servers with macros forced off + (`AutomationSecurity = msoAutomationSecurityForceDisable`). + - **No mark and no ask before opening** (owner decision 2026-11-13). Files the agent writes get no Mark-of-the-Web or + quarantine flag, and the engine's `app open` and `window launch` open any file you could open. The r3 file-type + rule is dropped: it held only for the engine's own verbs, and any app's file dialog went around it (lens N1). + Every open of an executable or script by an agent-caused process is logged as a run (rule 7). +5. **Protected store** (guard service, lens B3). The policy, ask answers, recipes, approved scripts, named commands, + bridges and pause state live in a store owned by the guard service. Your account and the host account can only read + it; the agent group cannot read it at all (lens n4). Changes go through the guard: the helper asks, the guard writes, + and widening follows §4.2, with the consent the guard checks itself. A store file edited outside the guard (by an + admin) fails the guard's signature check on load and is ignored until you confirm it in the tray with the hardened + T3 confirm (§4.2; recovery after a power cut: §12.3). The engine refuses acting on any window whose document is in the store's folder + and refuses file-dialog navigation into it. +6. **Undo** (guard service). + - **Snapshots:** OS snapshots of the volumes in the reach map, taken by the guard before an agent write or run when + the last one is older than 30 minutes. The wind-down snapshot (§12.2) reuses the last snapshot only if no agent + write came after it; otherwise it takes a fresh one. Applying a speculative winner (§7.4) takes **no** extra + snapshot: it journals pre-images of every file it changes (correctness E). At most 48 a day, and the guard counts + System Restore and other copies on the volume against the Windows Volume Shadow Copy limit of 64. **At the cap** + (48 a day, or 64 on the volume) the guard does not try a new one: it reuses the newest snapshot, logs that the + undo point is older, and goes on; agent work is not refused for it (T3 file tools and winner applies still have + exact pre-images). **A snapshot it tries to take and that fails, or a newest snapshot that is gone** (Windows + deleted it because shadow storage filled, macOS under disk pressure), **refuses** agent writes and runs until a new + one is taken (fail closed, below; correctness minor 8). Windows: Volume Shadow Copy; macOS: APFS + local snapshots (`tmutil localsnapshot`); Linux: btrfs/ZFS/LVM-thin snapshots where the folder lives on one. + - **Where there are no usable snapshots (correctness review).** Linux on other filesystems: `fanotify` reports a + change only after it happened, so nothing can copy a file before a command changes it. There, **command changes + have no undo**; only T3's file tools undo (pre-images). `host_status` reports `undo: file tools only`, the tray + shows it, and the install suggests a btrfs or ZFS volume for the reach map. macOS deletes local snapshots by itself + under disk pressure, and Windows deletes old shadow copies itself when the shadow storage fills from any writer + (correctness minor), so neither the budget nor the undo window below can be fully promised there; the tray warns + when one disappears early. + - **Pre-images:** T3's own file tools (`host_write`, `host_move`, `host_delete`) keep the previous version of each + file they change in the undo store, so their changes undo exactly, not only to the last snapshot. + - **The undo store does not fill in normal use (owner answer 6, 2026-11-13).** The undo store and the snapshot + storage share one disk budget: + - **sized from measured need:** the guard records how much undo space agent work used per day on this computer and + sizes the store for the undo window (24 hours, setting) of the busiest day seen so far, plus a margin; the first + time it uses a default estimate; + - **on a drive with room:** the store, and on Windows the shadow-copy storage area (which can live on another + volume), go on the fixed drive with the most free space that the agent group cannot write; + - **grows within a budget you set** (setting; the install suggests one from the free space); + - **prunes only what is safe:** copies that belong to goals you accepted or that have finished, and only once they + are older than the undo window. Never a held or quarantined item, never anything inside the undo window; + - **warns early:** at 80 % of the budget the tray says so, with what fills it and "raise the budget"; + - **refusing is the last resort, on by default:** only if the budget is still full, an agent write whose + pre-images would not fit, or that would push out a snapshot inside the undo window, is refused, with a clear tray + message (what is full, by what, and the choices: raise the budget, accept finished goals, or turn this guard off). + A `host_delete` of a large tree that would not fit is refused with the size needed, never run without undo + (lens n7). With the guard turned off, snapshots older than 1 hour (the floor) may be pushed out, and the tray + names the agent changes that lost their undo. + - **Held and quarantined items are neutralised in place, never copied (lens P7, sec S6, correctness r3 minor 2).** + A quarantined auto-run path (rule 3) or a held protected-area change (§4.4) is made inactive where it is, with no + copy: a file or folder is **renamed** into a guard-owned folder on the same volume (one per volume, made at + install, the agent group denied; a rename needs no space, so it cannot fail for lack of room); a registry value is + moved under a guard-owned key; a scheduled task, service, unit or launch item is disabled and its definition moved + the same way. Then the existing object is restored from the guard's own copy (next point), as the place's writer, you (§3.4 audit + rule 1). Keep moves it back (the guard undoing its own take-away, the exception in §3.4 audit rule 1); + Remove deletes it (link-safe). Neutralised items are outside the budget's pruning and the undo window: an item + waiting for Keep or Remove never expires, however long it waits. This **removes the whole class of "the held area + is full, so the change stays"**: there is no per-writer quota and no unknown-writer share, since nothing held + needs room, and a flood of large changes cannot make a small one stick. The **reserved area** of the undo store + holds only the guard's own copies, with a size cap per copy (setting, default 10 MB); a larger watched object is + reverted from the newest snapshot instead, as for large folders (next point). If the reserved area itself is full, + every watched object without a copy is treated as a large one, all new agent work is refused until there is room, + and the tray shows it in red. State: a neutralise stopped half-way (renamed, copy not yet restored) is finished at + the next guard start from the ledger line written before the rename. + - **The guard's own copies (lens M6).** The guard keeps its own copy of every watched persistence object and every + small watched system spot (registry values, shell profiles, plists, unit files, Startup entries), taken when the + watch starts and refreshed only after a change whose writer is **known** and not agent-caused, while no + lost-events report and no guard-down gap is open (sec T3). A change by an unknown writer is never copied in: the + tray shows it with Keep (which then makes it the new copy) or Remove. A held change to an existing + object is reverted **from that copy**, which needs no pre-image at the time of the write. For a large watched + folder the revert comes from the newest snapshot; the tray names the snapshot's time and the files it would roll + back, and nothing is applied without you. The copies live in the reserved area; a missing copy (first start, + store lost, an upgrade) is taken again from the current state at the next guard start, before any agent work, and + is marked **unverified**: its object is shown once in the tray for review, and a change to it is never reverted to + an unverified copy without your OK (correctness r3 minor 1). + - **Writer recorded:** the guard records which account and which process changed each file (Windows ETW kernel file + events, macOS Endpoint Security, Linux `fanotify`), using the real creator of each process (§4.4). **Where the + writer cannot be told** (sec T3): the change counts as the agent's, and is held where §4.4 holds agent changes, + while any agent-marked process is alive, during an acting window (§4.4), after a lost-events report + (ETW buffers lost, a watcher overflow; an agent can cause these by flooding) until the guard re-baselined every + watched object against its copies, and for every change found after an **unclean** guard-down gap. **A gap after a + clean guard stop** (shutdown or restart, with no agent-marked process alive and no open acting window in the + journal) **counts as yours**, so a reboot with Windows updates or an app updater at boot is not held (correctness + r3 major 3). After an unclean gap (a crash, a power cut, or a stop while one of those was open), an unknown-writer + change is reverted only for objects the agent group or an agent-caused process could have written; a change to an + object that needs elevation (machine-wide tasks, services, `HKLM`, system units) is logged and shown for review, + not reverted. Only outside all of these does an unknown writer count as yours. A process that escaped the mark and + writes during a clean gap is the residual of §14. + - **Fail closed (lens M8a):** if a snapshot cannot be taken, agent writes and runs are refused and the tray says + why. The agent account's disk quota stops it filling the disk to push snapshots out. + - **Agent undo restores only agent changes (lens M9):** an agent-called `host_undo` restores only files whose + changes in the window came from the agent account, and only if the file still has the hash the agent left it + with. Files you changed are listed, not touched; restoring them is offered only in the tray. + - **The real guarantee (lens N9).** T3 file tool changes undo exactly for the undo window (pre-images). Other changes + go back to the last snapshot before them for the whole undo window while the last-resort guard is on (the + default), and for at least 1 hour with it off. Not covered: command changes on Linux without btrfs, ZFS or + LVM-thin; snapshots macOS deletes under disk pressure; changes **inside apps** unless the app saves into the reach + map. +7. **Log** (guard service is the authority, lens m2). The guard's log records every file change with its writer, + every batch and step the engine ran (app, target, action, rung, result, timing), every ask and answer, take-over, + pause, wind-down and kill switch. The T3 log (host account) records every host tool call and its caller (§9): who, + tool, arguments without file contents, result, bytes, time. Each line carries the hash of the line before; every 5 + minutes the guard countersigns the T3 log's head into its own log, so a forged or cut T3 log shows. A call whose + log write fails is refused. + - **What the agent wrote and ran** (owner decision 2026-11-13). A tray view, also in `host_status`, lists every file + written by the agent group or by an agent-caused process as you (§4.4), and every program and script an + agent-caused process started, with time, task and thread, writer, path, hash, and the file's last writer (so a run + of a file the agent wrote stands out). Writes to build files that IDEs sync (rule 3) and every held protected-area + change are flagged. Each line offers Undo or Remove where one exists, and each goal offers **Accept** (its undo + copies may then be pruned once they are older than the undo window, rule 6). +8. **Pause, wind down, kill switch, revoke all** (tray, §12). _Pause_: every host tool and every new turn is refused + with "paused by owner"; **every agent-account process** is ended (the T3 server is the host account, so nothing it + needs survives; daemonised children and scheduled runs go too, lens m1); batches stop; held keys and buttons are + released; "this task" grants end; agent-caused processes running as you (§4.4) are listed in the tray with "end" + (they may be apps you now use, so pause does not end them). _Wind down_: §12.2. _Kill switch_: the guard ends every + agent-group process **and every agent-caused process running as you**, stops the engine and the host account's T3, + and disables both accounts until you turn them back on (with the hardened confirm the guard checks, §4.2, or where + that is refused by re-running the admin install). _Revoke all_: every link + to and from this computer is revoked (§10). Pause and wind-down rely on the guard and the helper; the kill switch + relies only on the guard and the OS, not on T3 or the helper. **Narrowing is durable before it is confirmed (lens N8):** pause, kill switch, revoke, Deny + and narrowing the reach map are written and fsynced by the guard, in the store and as a narrowing record in its log, + before the tray shows them as done. +9. **Availability and who may call** (agent machine, then the computer; lens M6). + - Per computer, on the agent machine: Settings → Linked environments → _computer_ → Host tools: **on** (default), + **off**, or **off until** a time. Stored in `ServerSettings`. + - Per project, on the agent machine: **host tools for this project** on or off (a project setting, **default off + for new projects**, owner decision 2026-11-13; one toggle each). Your own machines and projects are trusted; this keeps them away from projects + that mostly read outside text. + - Per thread: a thread marked **outside input** (set by you, or by a launcher for work on outside contributions, + public issues or web research) gets the read tools at most. + - The agent machine forwards the calling project and thread id with each call; the computer logs it and the tray + shows it while acting. + - These are convenience switches on the agent machine; the computer's own pause, wind-down and kill switch win. + +**No activity gate.** Host tools do not check whether you are gaming or busy. Work you asked an agent to do runs while +you do anything else. Only §11's background runners adapt to the free hardware. + +### 4.4 Acting as you, and protected areas (owner decision 2026-11-13, lens N1) + +**Said openly: when the agent acts in your apps' UI, it acts as you.** Any app with a file dialog, a launcher, a link, +a built-in terminal or a macro console can run a file with your rights, including a file the agent wrote, and the OS +shows no warning: it sees you opening a file. Files the agent writes are not marked as downloaded, and nothing asks +before opening (§4.3 rule 4). The full-user-rights list (§4.2) closes the direct routes (terminals, launchers); it is +not a wall around the rest. Once agent-caused code runs as you, it can do whatever your account can. What holds +instead: + +1. **Protected areas** need your OK for each single action, enforced by the OS and deny entries where they can be, + otherwise held by the guard (below). +2. **Every file the agent wrote or ran is logged and shown** (§4.3 rule 7). +3. The sensitive set (§4.1), take-over (§6.7), undo (§4.3 rule 6), the guard-checked consent (§4.2) and the kill + switch, which also ends agent-caused processes running as you. + +**Protected areas** (one OS-neutral list of kinds in the core, with per-OS entries; the policy can add entries, never +remove a built-in one): + +| Kind | Windows | macOS | Linux | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| System | `C:\Windows` (System32 and the rest), `Program Files*`, `ProgramData`, the root of each fixed drive, boot and EFI partitions, the HKLM registry | `/System`, `/Library`, `/usr`, `/bin`, `/sbin`, `/etc`, `/private`, `/Applications`, the Homebrew prefix (`/opt/homebrew`, `/usr/local`), boot volumes | `/etc`, `/boot`, the EFI partition, `/usr` (with `/usr/local`), `/bin`, `/sbin`, `/lib*`, `/opt`, `/var/lib` | +| Other users | other profiles under `C:\Users` | other folders under `/Users` | other homes, `/root` | +| Autostart and persistence | Startup folders (yours and all users'), Run and RunOnce keys (HKCU and HKLM, both views), scheduled tasks, services and drivers, WMI event subscriptions, Winlogon, Image File Execution Options and AppInit keys, per-user COM and file-association overrides (`HKCU\Software\Classes`), PowerShell profiles, Office add-in and template folders, browser extension folders, **WSL boot config** (`.wslconfig`, each distro's `/etc/wsl.conf` `[boot]`), the folder redirect keys (`HKCU\...\Explorer\User Shell Folders` and `Shell Folders`, which can point Startup elsewhere) | LaunchAgents and LaunchDaemons (yours and the system's), login items and Background Task Management items, `cron`, `periodic`, shell profiles, `/etc/paths.d`, `launchctl setenv` and `~/.MacOSX/environment.plist`, browser extension folders | systemd system and user units, timers and generators, `cron` and `anacron`, `/etc/profile.d`, shell profiles, XDG autostart (`~/.config/autostart`), `environment.d` and `~/.pam_environment` (which can move `XDG_CONFIG_HOME` to an unwatched folder), udev rules, browser extension folders | + +**What "your OK per action" means.** A write, delete or run in a protected area is one `host_ask` item (§4.2) that +names the exact path, key or command, shows the content or the diff, has the fixed scope **once** and is never +remembered, and is confirmed through the hardened confirm bound to that item, which the guard checks itself +(§4.2); an elevated run also shows the OS's own prompt. **Reading** +protected areas stays allowed, except secrets (§4.1). + +**"Run" (owner answer 1, 2026-11-13)** is any of: running a file **the agent itself placed** in a protected area (a +file whose recorded writer is the agent group or an agent-caused process, including one you kept after a hold); +running **anything elevated** (admin or root); and anything that **registers autostart or persistence** (a service, +driver, scheduled task, boot or login entry). Running an ordinary installed program (a shell, git, a compiler) with the +agent account's or your rights is not a protected-area action. + +**How a run of an agent-placed file is stopped (lens M5).** Ending it at its process-start event is too late (ETW +delivers events in buffers, about 1 s), and a DLL load or a script run through an interpreter is no start of that file +at all. So: + +- **Windows:** as soon as the guard records an agent-placed file in a protected area, it puts a **Deny + `FILE_EXECUTE` entry for Everyone** (SYSTEM included; link-safe, §3.4; the guard owns the file's ACL for that) on it. + Windows checks `FILE_EXECUTE` when it maps an image, so starting it as a program and loading it as a DLL into a + service or one of your apps are both refused before any code runs, with no driver. A script is read, not mapped, so + the guard also denies `FILE_READ_DATA` to everyone but itself on **every** agent-placed file in a protected area, + whatever its name or extension (lens m3: a renamed copy or a script with no extension is covered); it waits for + Keep or Remove anyway. **Race, said openly:** the guard learns of + the write from ETW, so between the write and the deny entry (about 1 s) the file can run; the driver of §17 would + close that. +- **macOS and Linux:** the guard denies the open itself (Endpoint Security `AUTH_OPEN` and `AUTH_EXEC`; `fanotify` + `FAN_OPEN_PERM` and `FAN_OPEN_EXEC_PERM` on those files) for everyone but the guard, which covers library loads and + interpreters too, and blocks before the write is visible, so there is no race. So that this holds **while the guard + is down** too (lens m3), the guard also makes each such file owned by root with mode `000` (macOS: plus a deny ACL + entry for everyone), which no one but root can read or run. +- **While the guard does not answer** permission events (crash, restart, hung), host tools and acting are off: the + guard's agent job ends with the guard (§6.1) and the engine pauses (§6.7). `fanotify` lets pending events through when + the guard's descriptor closes, so this is the only safe state. +- **Keep** of such a file (the hardened confirm, once) removes the deny entries and gives the file back its owner + and mode (the guard undoing its own take-away on the same file, the exception in §3.4 audit rule 1): later runs need no OK, since you chose to keep + it. **Remove** deletes the new file. `host_run`, the engine and the helper refuse such a run before starting it. + +**Who enforces it:** + +| Route | Enforced by | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Commands (agent group) | **the OS and deny entries:** standard accounts with no admin rights and no way to elevate (they have no interactive session of yours to show a prompt in); every spot of a protected area that the OS leaves writable to users (`ProgramData`, a drive root, Program Files folders installers left writable, HKLM keys that grant Users write, `/opt` and `/usr/local` subtrees) carries Deny write, delete and create-child entries for the agent group, found and checked by the reach check (§3.3, lens P1); other profiles are not readable or writable to them; their own persistence points are denied (§3.1) | +| Acting as you: system areas that need elevation | **the OS:** the elevation prompt is on the secure desktop or in `SecurityAgent` / the polkit agent, which the engine refuses (§6.7), so only you can approve, once. Setup sets UAC to "Always notify" (owner answer 4) with the secure desktop, and the verify requires no `NOPASSWD` sudo rule for your account (§3.3); on Linux and macOS the guard clears your sudo timestamps when an acting batch or a full-rights grant starts, so a recent `sudo` of yours in an open terminal does not carry over | +| Acting as you: system spots your account writes without elevation | **the guard**, the same two layers as your persistence points (next row). The reach check walks the protected areas as your standard (filtered) token and lists every such spot (`ProgramData` folders, user-writable Program Files folders, writable HKLM keys, `/Applications` for an admin, the Homebrew prefix, `/opt` or `/usr/local` subtrees you own); each joins the watch (layer 1); only a change by an agent-caused process is held there, so your own app updates go through while the agent acts (correctness D) | +| Acting as you: your own persistence points | **the guard**, two layers. (1) **Watch always:** Windows ETW registry and file events plus Task Scheduler and service-control events; macOS Endpoint Security, which can block; Linux `fanotify`, plus a BPF LSM where the kernel has one, which can block. On a **persistence point**, a change by an agent-caused process, or any change during an **acting window** (while an acting batch or a full-rights grant runs, and for 2 minutes after, setting; a plain time window, nothing is locked), is **held**; on the **system spots** of the row above, only a change by an agent-caused process is held, so your own app updates and SYSTEM-run writes go through (correctness D). Held means: blocked where the OS can block, otherwise **neutralised in place** (§4.3 rule 6: the file renamed into the guard's folder on the same volume, the value moved under the guard's key, the task, unit or service disabled) and the existing object restored at once from the guard's own copy, as you (§3.4 audit rule 1; lens M6), and offered in the tray and the next ask with Keep (the hardened confirm, once) or Remove. An existing file or key is never deleted as a fallback. When the guard holds or reverts a scheduled task, unit, LaunchAgent, service or WMI entry, it also **ends every process that entry started** (Task Scheduler process-creation events carry the process id; the unit's cgroup; the launchd job), since such a process may have run in the second before the revert (lens P4). Other new entries (your own installs) are shown in the tray with Remove and logged, not held. (2) **Baseline diff (lens N4):** each full-rights grant and each visible batch records the persistence points at its start and diffs them at its end; every addition is shown with Remove and logged | + +**Agent-caused processes.** The guard marks as agent-caused: every agent-group process; every process the engine +starts; every process started as you by an app the engine acted in, during the batch or within 10 s after (setting); +**every process started as you by the Task Scheduler, WMI, COM/DCOM activation, the Explorer shell-execute path, +`systemd --user` or `launchd` while any marked process is alive, or during an acting window** (lens +P4, M7: these break the parent chain; the WMI-Activity event's client process id is used where it exists); every +process started by an entry the guard held; and the descendants of all of these. Descendants are counted by the +**real creator** (lens M7): Windows the ETW event header's process id, not the `ParentProcessID` field, which a caller +can set to any process (`PROC_THREAD_ATTRIBUTE_PARENT_PROCESS`); macOS the Endpoint Security parent audit token, not +the "original ppid"; Linux the proc connector's fork events. The mark is still best effort: **COM into the running +Explorer** (`ShellWindows`, `ShellExecute`) starts a child of Explorer that is marked only while a marked process is +alive, and a start through such a service after the last marked process ended is not marked (named in §14). The +acting window and the baseline diff above do not depend on it. **Agent-group processes outside a job** the guard or the broker owns (for +example one started through a service) are ended by the guard at once and logged. + +**Marks and baselines survive a restart (correctness review).** The guard journals each mark (process id **and** start +time, so a reused id is never hit) and each grant's persistence baseline in its ledger. On start it re-attaches to the +marked processes that still run (the kill switch can still end them), and marks every process whose real creator is a +marked process and that started while the guard was down. Agent-group processes are found by **job membership** +(Windows; every one runs in the guard's job, §6.1) or the agent cgroup (Linux), never by the parent id, which a process +can set and which is reused after the parent exits (correctness minor 11). Processes running as you are caught up from +the live process tree with start times (a "parent" younger than its child is not counted), and for the whole gap the +unknown-writer rule of §4.3 rule 6 applies, so what the tree cannot prove is held, not trusted. For a grant that never reached its end it diffs against the journalled baseline; if the baseline is +missing, it shows every persistence entry newer than the grant's start. + +**What is left** (also in §14): code that runs as you can read and change everything your account can outside the +protected areas, including your profile's secrets, for as long as it runs. A process that escaped the agent-caused mark +is **not** ended by the kill switch and survives a restart if it set up persistence the guard missed (lens p1). It can wait until the acting window has passed +and use a persistence point not on the built-in list, or change a program you run that lives in a writable folder (a +tool in `~/bin`, a global package), which only the activity log shows. On Windows, `wsl -u root` needs no password, so +such code is root inside your WSL distros; only their boot config is watched. Setup sets UAC to "Always notify" +(owner answer 4); if you turn that off and use an administrator account, known auto-elevation tricks get admin rights +with no prompt, and that defeats the guard-checked consent, the protected store and **every** protected area, not only +system writes (warned in §3.3 and the tray; lens p1). A user-writable spot that a SYSTEM service or an elevated task +loads from is a way to SYSTEM for code that escaped the mark, and SYSTEM can turn the guard off (lens n3); the reach +check names such spots, and the tray offers "fix this folder's permissions" for each. On Windows the guard blocks +changes only by ACL in v1 and reverts a change after the fact; a signed minifilter and registry callback would let it +block per process before the fact. That driver comes only if measurements show it is needed (owner answer 5, §17). + +## 5. Seeing and acting: principles (owner decision, speed first) + +The agent works in your apps through our own layer, built from scratch for agents, not by imitating a person with a +mouse and screenshots. Seven rules: + +1. **Most suitable tool first, pixels last.** For each job the agent uses the first route that works: + 1. **shell, CLI and files** as the agent account (`host_run`, file tools): a build, a git command, a file export; + 2. **app and OS automation APIs**, through typed adapters in the engine: Windows COM / WinRT / Shell, macOS Apple + Events (AppleScript, JXA) and app scripting dictionaries, Linux D-Bus (desktop portals, MPRIS, app interfaces); + 3. **app-side bridges** (§6.6): a game mod, a browser over the DevTools protocol (CDP), an editor extension; + 4. **accessibility-tree actions on elements by id**: invoke, toggle, set value, select, expand, scroll into view, + with no mouse movement; + 5. **input**, down the input ladder of §6.4: window-targeted events, then the agent's own screen, then real + synthetic input on your desktop only last. +2. **Few round trips.** One `host_do` call carries a list of steps (§7), with waits on conditions that the engine checks + **on the computer**, and returns only the results and what changed. No screenshot polling over the network. +3. **Compact observations.** Filtered text snapshots, then **only what changed** (§6.2). Screenshots only when asked + for or when an app has no structure, cropped and scaled down. +4. **A latency budget with numbers** (§8), measured against a mouse-and-screenshot baseline. +5. **Reusable named recipes** (§7.3), replayed at engine speed, each remembering which input rung works. +6. **Invisible by default** (owner decision 2026-11-13, §6.8). +7. **Try alternatives at once when unsure** (owner decision 2026-11-13, §7.4), on private copies. + +## 6. Seeing and acting: the engine + +### 6.1 Who runs it, and how that fits the accounts + +Files and commands run as the **agent account** (§3). Seeing and using your apps cannot work that way: only a process +in **your** desktop session, as you, can read your windows' accessibility trees, capture your screen and send input to +your apps (Windows session isolation and UIPI; macOS grants Accessibility and Screen Recording to an app in your GUI +session; Wayland gives these only to the session's own clients). So: + +- The **engine runs as you**, started by the session helper, and is the only part with that power. +- It has a **local channel** with a fixed set of verbs: a Windows named pipe or a Unix socket whose ACL admits **only + the host account** (the T3 server). The agent account, its commands and speculative runs cannot connect (lens M1, + M2). The engine still **enforces the policy itself**: pause, wind-down, the sensitive set, the high-risk list, the + take-over pause (§6.7), limits, and its own log line to the guard. The T3 server's checks come first but are + not relied on. +- The engine runs **no free-form script as you**. Automation adapters are typed verbs shipped with the fork. A raw + script (PowerShell, AppleScript, a shell line) runs as you only if it is an **approved script** in the protected + store, from a frozen copy (§4.3 rule 4). Agent-written scripts run as the agent account through `host_run`. +- The engine never runs elevated. Windows: no `uiAccess`, medium integrity, so admin windows are out of reach by UIPI. + macOS: no root helper. Linux: no root. +- **The engine worker** (§1) is the same binary in worker mode, started by the **guard** as the agent account with a + batch logon (§3.1), inside the **guard's agent job**, the same job the broker and every agent command run in (one job, + kill-on-close, lens m13), so it ends when the guard ends. It owns the agents' headless + browser (§6.5) and, on Linux, the agent's own headless display (rung 3). It has no channel into your session and + none of the engine's power. **Its channel (lens M4):** the guard makes an unnamed pipe pair at start and passes one + end to the worker as an inherited handle and the other to the host account's T3 server; there is no named endpoint + an agent command could open or squat. **The host account's T3 drives the worker**, so the agent's browser works with + nobody signed in. **Every check runs outside the agent account** (in T3's host toolkit and the guard): the worker + runs as the same account as every agent command, so an agent command could attach to it, and a check inside it + would protect nothing. Taking the worker over gains such a command nothing, since the worker has only the agent + account's rights. Its replies (page trees, screenshots) are **untrusted data**: size limits, a strict parser, no + field that names a path or a command to run. +- **Heartbeat (lens M8d):** the helper draws the indicator; the engine stops acting within 500 ms when the helper's + heartbeat stops, releases held input, and refuses acting until the helper is back. It never acts without an + indicator. + +**What this means plainly:** with A's open scope, the agent can do in a non-sensitive app what you could do there by +hand, with your rights, and that includes running files through a file dialog, a link or a launcher (§4.4). Terminals +and launchers need the batched ask (§4.2); protected areas, the activity log, undo, take-over and the kill switch are +what limit the rest. + +### 6.2 Seeing: sources, which wins, and how the agent finds them + +`host_observe` returns, in one call: + +- **Windows and focus:** every visible top-level window (id, app, process, redacted title, bounds, monitor, + minimised, focused), and which element has keyboard focus. Cheap; always included. +- **One structured snapshot per named window**, from the best source that has data for it: + +| Order | Source | Used when | What it gives | +| ----- | -------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 1 | **app-side bridge** (§6.6) | a bridge is allowed for that app and answers | the app's own state, exact (what the player looks at, inventory, tooltips, …) | +| 2 | **automation adapter** | the app has a typed adapter (§5) whose read verbs cover the request | documents, selections, open items, as data | +| 3 | **accessibility tree** | the app exposes one (most normal apps, Electron with accessibility on, browsers' chrome) | elements with id, role, name, value, state, bounds, available actions | +| 4 | **screenshot** | asked for, or sources 1-3 give a **sparse** result (games, canvases, remote-desktop windows) | a cropped, scaled image of the window or a region | + +Sources 1 and 3 are combined when both have data (a browser: CDP for the page, the tree for the browser's own UI). +A tree is **sparse** when a visible window of more than a small area has fewer than a few named elements; the +snapshot then says `sparse: true` and suggests a bridge or a screenshot. + +**Snapshot format.** Indented text, one element per line, `[id] role "name" = value {states} @x,y,w,h`. Filtered by +default: only visible, named or actionable elements; containers with one child are folded; long lists are cut to the +visible part with a count. A size cap (default 6,000 tokens per window) cuts deepest-first and says so. Element ids +are stable for as long as the element lives, so a step can name `[e412]` minutes later. **Selectors** +(`role=button name="Save" in window title~"Report"`) are the stable form for recipes. + +**Diffs.** Every snapshot has an id. `host_observe {since: }` returns only added, removed and changed lines. The +engine keeps a live cache of each watched window's tree, updated from the OS's change events (UI Automation event +handlers with a cache request; macOS `AXObserver` notifications; AT-SPI events over D-Bus), so a diff is a read from +memory, not a new walk of the app. + +**Screenshots.** Window or region crop, scaled to a maximum edge (default 1280 px), WebP or JPEG. A `zoom` region +returns a sharper crop. Windows of the sensitive set, and windows not named, are blacked out. The on-screen indicator +and the confirm panel (§4.2) are excluded from captures (Windows `WDA_EXCLUDEFROMCAPTURE`; macOS window `sharingType = none`; Linux: left out of the +crop where the compositor allows, to verify). + +**Untrusted marking.** Everything read from an app, a page, a bridge or a file is marked in the result as untrusted +content, so the agent and its tool descriptions treat instructions in it as data. + +**Discovery.** `host_status` lists what the computer offers: the OS and its accessibility back end and permission state +(macOS Accessibility and Screen Recording granted or not), the input rungs available (§6.4), the allowed bridges and +their verbs, the automation adapters, the high-risk grants in force and their scopes, and the saved recipes. +`host_observe` names the source used per window. + +### 6.3 Acting + +All acting goes through `host_do` (§7). The step kinds, in the order agents should prefer them: + +| Step kind | What it does | Per-OS back end (Windows / macOS / Linux) | +| ----------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run` | a command as the agent account (same as `host_run`) | broker; job object / process group / cgroup | +| `app` | a typed automation verb (open a file in an app, read a document, menu command) | COM, WinRT, Shell / Apple Events, scripting dictionaries / D-Bus, portals | +| `bridge` | a verb of an app-side bridge (§6.6) | the bridge's own channel | +| `el` | an element action by id or selector: invoke, toggle, set value, select, expand, focus, scroll into view | UIA patterns (Invoke, Toggle, Value, SelectionItem, ExpandCollapse, ScrollItem) / AX actions and attributes / AT-SPI Action, EditableText, Selection | +| `window` | focus, move, resize, minimise, close a window; launch an app | Win32 / AppKit / X11 EWMH or the compositor's portal | +| `clipboard` | read or set the clipboard (§6.7) | Win32 / NSPasteboard / X11 selections, Wayland data-control | +| `input` | keys, text, clicks, drags, scrolls at a window-relative point or element, down the ladder of §6.4 | §6.4 | +| `wait` | until a condition holds or a timeout (§7.1) | event-driven where the OS sends events, else a 25 ms poll | +| `observe` | add a snapshot or diff (or a crop) to the result at this point | §6.2 | + +`app open` and `window launch` open any file you could (§4.3 rule 4); opening an executable or a script is logged as a +run. `input` steps aim at an element (its centre, or an offset in it) or at a point relative to a window, never at a +bare screen point. + +### 6.4 The input ladder (owner decision 2026-11-13) + +The agent **may** simulate mouse and keyboard when it needs to, but must **never take over your real cursor or +keyboard when a lower-level way exists**. Each `input` step (and each `el` action) runs on the fastest, least intrusive +rung that works for that app; the engine steps down only when a rung did not work. + +| Rung | How | Reaches | Cannot reach | Speed (p50) | Your cursor and keys | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------- | +| 1 | **Actions with no coordinates:** UIA Invoke / Value / Toggle patterns; macOS AX actions and `AXValue`; AT-SPI Action and EditableText; app bridges; CDP `Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, `Input.insertText` for the agents' browser | standard controls in most apps, browser pages, any app with a bridge, background windows | custom-drawn UIs, canvases and games without a bridge; controls that expose no pattern | dispatch ≤ 10 ms; 1,000 chars of text in one `set_value`/`insertText` ≤ 20 ms | untouched | +| 2 | **Events sent to one window** at window coordinates, cursor not moved: Windows `PostMessage`/`SendMessage` of `WM_*BUTTON*`, `WM_MOUSEWHEEL`, `WM_KEYDOWN`/`WM_CHAR` to the window handle; macOS `CGEventPostToPid`; X11 `XSendEvent` (xdotool `--window`) | classic Win32 and many toolkit apps, many macOS apps for keys, X11 apps that accept sent events | games using Raw Input, DirectInput or `GetAsyncKeyState`; some Chromium content areas, UWP/WinUI windows and apps that check real key state; windows of higher integrity (UIPI blocks them); X11 apps that drop `send_event` events (xterm by default); native Wayland apps (no such channel) | ≤ 1 ms per event; 1,000 chars ≤ 60 ms | untouched | +| 3 | **A hidden screen** (owner decision 2026-11-14). Linux: **the agent's own headless display**, run by the engine worker as the agent account (Xvfb or a headless compositor: weston or wlroots headless, gamescope headless). Windows: a **hidden desktop in your session** (`CreateDesktop`), run by the engine; its apps run as you, so it is acting as you (§4.4, all its rules). macOS: none in v1. The agent has no screen of its own on Windows or macOS in v1; the options for later are in §6.9. A child session of your own account was tested and refused ("Windows Hello sign-in only", spike 2026-11-13) | apps opened there (Linux: as the agent account; Windows: as you); on Linux real XTest/libei input into its own display (games included); on Windows rungs 1-2 inside it (`SendInput` reaches only the input desktop) | apps already open on your screen (they would have to be reopened there). **Single-instance apps** (Office, browsers, Explorer) hand a start on the hidden desktop to the copy already open on yours, so the window would appear in front of you: **before** it starts an app there, the engine checks whether that app already runs in your session, and if so uses rung 4 with your OK instead of starting it (correctness r3 minor 7); it still checks that each window it gets is on the hidden desktop (`GetThreadDesktop` of its thread) and otherwise does nothing in it and falls to rung 4 with its rules (correctness minor 9); on Windows and macOS, apps that need real hardware input; GPU apps where the hidden display has no GPU (to verify per OS) | as the rung used inside it, plus the app's start | untouched | +| 4 | **Real synthetic input on your desktop:** `SendInput` / `CGEventPost` / XTest on X11, libei through the RemoteDesktop portal on Wayland (no `uinput` fallback: kernel-level input would reach the lock screen and polkit agents past every check, lens N7) | everything you can reach by hand | elevated and secure-desktop windows (UIPI, refused anyway) | ≤ 2 ms per event; 1,000 chars ≤ 300 ms with the checks below | **used**: needs `visible: true`, indicator, take-over | + +**How the engine knows a rung did not work, and steps down:** + +- Each action has an **expected effect**, given by the step or defaulted per verb: invoke → a tree event or diff on + the target; set value / typed text → the element's value; a click → a focus, selection or tree change, or, with no + tree, a pixel change in the target's crop; a bridge verb → its own result. The engine checks it within the rung's + settle time (event-driven; default at most 300 ms). +- **Immediate step-down:** the rung's API fails (UIA `E_ACCESSDENIED`, no pattern, `SendMessage` blocked by UIPI, + `CGEventPostToPid` refused, a native Wayland window for rung 2), or the app is on the engine's built-in list for that + rung (for example Raw Input games skip rung 2). +- **No double apply.** Before stepping down after no effect, the engine waits until the app is idle (Windows + `SendMessageTimeout(WM_NULL)` returns, `WaitForInputIdle`; macOS and Linux: no tree events for the settle time) and + checks again. For an action that is not safe to repeat (typed text, invoke of a button that sends or deletes) it + steps down only if the check is clearly negative (value unchanged, no event); otherwise the step returns `unknown` + and the agent decides from a fresh diff. +- **Rung memory.** The rung that worked is recorded per app and app version (and in recipes), so the next step starts + there. A step can pin a rung or cap the lowest rung (`maxRung: 3` = never touch the real mouse). +- Rung 4 is used only in a batch marked `visible: true` (§6.8); otherwise the step fails as `needs_visible` with the + rungs tried. + +**Security fixes fitted to each rung (lens M5):** + +| Rung | Focus and target race | Typing | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 1 | none: actions name the element; the engine checks before each action that the element still belongs to the same window and process (runtime id + pid) | `set_value` / `insertText` in one call; refused on password fields | +| 2 | none from focus (events go to a handle, not the foreground); between chunks the engine checks the handle still belongs to the same process, thread and class (handles are reused) | `WM_CHAR` in chunks of 32 characters with that check between chunks | +| 3 | none with your desktop; inside the hidden screen the same checks as the rung used there | as the rung used there | +| 4 | Windows `LockSetForegroundWindow(LSFW_LOCK)` for the burst; events in chunks of at most 8 with a foreground, target and pid check between chunks; abort on a foreground change (`EVENT_SYSTEM_FOREGROUND`, the `NSWorkspace` activation notification, `_NET_ACTIVE_WINDOW`), release held input, return `stale` | chunks of at most 8 key events with the same checks; text goes through rung 1 whenever the target allows it | + +**Take-over by rung.** Rungs 1-3 never move your pointer or type into the window you use, so the agent keeps working +while you use other windows. Take-over (§6.7) stops a batch on rungs 1-2 only when your real input goes to the same +window the agent is acting in. On rung 4, **any** real click or key press stops it (owner decision 2026-11-13). Either +way the agent then stays paused until you say continue (§6.7). + +**Rules that hold on every rung:** the sensitive set, the high-risk list (keys sent to a terminal by `PostMessage` are +still acting in a full-user-rights app), protected areas (§4.4), the indicator (for rungs 1-3 a quiet banner "agent working +in _app_"; rung 4 the full border), the log. + +### 6.5 Browsers (lens M10) + +- **The agents' browser, headless:** run by the **engine worker** as the **agent account**, so a page it visits has + the agent account's file rights, not yours, and its network (§3.5: the named services on your linked machines + only, unless a goal opened more). Used for local pages and the agent's own test servers. CDP over a pipe only the + engine worker holds (`--remote-debugging-pipe`), no local port. Driven by the host account's T3 through the worker + channel (§6.1), so it needs no desktop session. Its profile is in the agent account's home: saved cookies are + DPAPI-protected on Windows, so a reset of the agent's password (§3) loses them and its sites sign in again. +- **No visible agents' browser** (r3 had one running as you with browser policy). Dropped: per-user browser policy also + bound your own browser of the same brand (lens n3), and browsing on your screen is now done in your own browser. +- **CDP is never passed through:** the engine exposes typed verbs (navigate, read the page tree, element actions, the + `Input.*` calls of rung 1, screenshot), not raw protocol. +- **Your own browser** (your logins, owner decision 2026-11-13): browsing as you happens here, through its + accessibility tree or a bridge extension you allow (§6.6), which also offers typed verbs only. It is acting as you + (§4.4). Its password manager pages, autofill and saved-login screens are in the sensitive set. + +### 6.6 App-side bridges + +For apps with no useful accessibility data (games, canvases, custom-drawn editors), the app itself can expose its state +and verbs. A **bridge** is a plug-in inside the app (a game mod, an editor extension) that connects to the engine's +**bridge endpoint** (a local pipe/socket for processes running as you) and speaks **MCP**: its tools are its verbs, +each marked read or act, with a JSON schema. + +- **Allowing.** A new bridge shows its name, app and verb list in the batched ask (§4.2), one tick box per verb (all + ticked by default); verbs you untick stay off. A bridge whose file is in an agent-writable folder must be listed with + its hash, and a changed hash puts it back to "waiting". +- **Use.** Read verbs feed `host_observe` (source 1 in §6.2); act verbs follow the same rules as other acting, + including take-over: while you are using that app, its act verbs wait. Bridge read data (game chat, page text) is + marked untrusted. +- **Why preferred:** exact, fast (one local call), needs no window in front, never touches your cursor (rung 1). +- **Files** the app uses (worlds, saves, configs) are reached through the reach map and the file tools. + +### 6.7 Safety rules for seeing and acting + +- **The sensitive set is refused** (§4.1): lock screen and secure desktop (Windows: input desktop is not `Default`, + UAC, Ctrl+Alt+Del; macOS: `loginwindow`, `SecurityAgent`; Linux: the screen locker and polkit agents); password + fields (UIA `IsPassword`, AX secure text fields and `IsSecureEventInputEnabled`, AT-SPI `password text`): never read, + never typed into; credential dialogs; elevated windows; password managers and secret stores in the built-in list; a + window whose document matches a secret pattern; the tray, helper, ask dialog, the confirm panel (§4.2) and T3's + host-tools screens; windows and + file dialogs inside the protected store's folder. Seeing and acting return `refused` with the reason. +- **Full-user-rights apps** act only under an active grant (§4.2); without it they can be seen, not acted on. +- **Inactive session (lens m3, n5):** acting in your session is refused while it is locked, disconnected, switched away + or remote: rungs 1, 2 and 4, and rung 3 on a hidden desktop in your session (its apps run as you). Rung 3 on the + agent account's own headless display (Linux) and the agents' browser (engine worker) go on. +- **On-screen indicator.** While a batch acts, the tray icon changes and a small banner names the app, the step and the + rung. On rung 4 a coloured border runs around the screen and the agent's pointer is drawn. Drawn by the helper; the + engine stops without the helper's heartbeat (§6.1). +- **You take over at once.** The engine watches real input and tells it apart from its own (Windows: low-level hooks, + `LLMHF_INJECTED` / `LLKHF_INJECTED`; macOS: an event tap, our events carry a source tag; X11: XInput2 raw events by + device; Wayland: the compositor's idle and input signals, to verify per compositor). On take-over (rules per rung in + §6.4): the running step stops, every key and button the engine holds is released, queued steps are dropped, and the + batch returns `interrupted_by_user` with what ran and a fresh diff. When the engine cannot tell your input from its + own on a desktop (a compositor without the signals), rung 4 is not offered there. +- **Paused after a take-over (owner decisions 2026-11-13: no idle timer; owner answers 2 and 3).** After a take-over, + acting in your session (rungs 1, 2 and 4, and rung 3 on a hidden desktop in your session) **stays paused for the + whole computer**, not only for the task that was interrupted (lens: another task or agent must not carry on clicking + where you just took over). The pause stops **only on-screen acting**: commands, file tools, the agents' browser and + (Linux) rung 3 on the agent's own display go on (owner answer 2). Acting continues only when you press **Continue** + in the tray or in T3, or when the agent asks again through `host_ask` (item "continue on your screen") and you press + **Allow**; there is no automatic continue (owner answer 3). Both always go through the **T3 confirm** (§4.2, owner + decision 2026-11-14: no OS prompt), which the guard checks itself and which takes only real input, bound to that one + item; never from an MCP or agent session (lens P5). The pause is written to the protected store, so a restart keeps + it. **A lost guard channel counts as pause:** the engine stops acting at once and acts again only after the guard is + back and the pause state is read from the store. +- **Clipboard (lens M4, n2).** Read is refused when the content carries a concealed or excluded marker (Windows + `ExcludeClipboardContentFromMonitorProcessing` or `CanIncludeInClipboardHistory = 0`; macOS + `org.nspasteboard.ConcealedType`; KDE `x-kde-passwordManagerHint`), and token shapes are redacted. A write counts as + acting: logged, indicator on, and the previous content is **put back when the batch ends**. A step may mark its + result "for you" (left on the clipboard); then the tray shows the kept content in a notification, so you see what you + would paste. Your previous clipboard is journalled for the restore (§15); content with a concealed marker is + journalled only encrypted for you (DPAPI, the keychain, the Secret Service) and deleted after the restore. +- **Redaction everywhere (lens M4):** token-shape redaction applies to every output path and every stored record: + observe text, titles, diffs, bridge results, journalled results returned on retry, `host_run` arguments and output, + both logs. Typed values are logged as length only when the target is a field marked sensitive or the text matches a + token shape. +- **Limits.** Per batch: steps (default 200), wall time (default 2 min), typed characters, and waits (each needs a + timeout). A batch is checked as a whole before it starts and each step again when it runs. + +### 6.8 Invisible by default (a tested requirement) + +**Owner decision (2026-11-13):** everything the agent runs on the computer (commands, scripts, batches, adapters, +helpers) runs in the background: no window is shown, none comes to the front, focus is never taken, and no console +flashes. Only UI work you asked for touches the visible screen. **Requirement: no window ever pops in front of you +unless a step you asked for put it there.** + +**Visible steps are declared.** A `host_do` batch may contain visible steps (rung 4 input; `window` focus, move, launch +shown; `el` actions that need the window in front; `app` verbs marked `visible`) only if the batch says +`visible: true`. That flag turns on the full indicator and is logged. Without it, visible steps are refused before the +batch starts, and every other step runs off-screen (rungs 1-3 need no window in front). + +**How each part stays off-screen:** + +| Part | Windows | macOS | Linux | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `host_run` (agent account) | the guard starts the broker with a batch logon, so its processes live in a non-interactive window station and **cannot** draw on your desktop; children are started with `CREATE_NO_WINDOW` anyway, a pseudo-console (ConPTY) only when a program needs a terminal | a LaunchDaemon has no GUI session; processes cannot reach the window server | no `DISPLAY`, `WAYLAND_DISPLAY` or session bus is passed; the unit has no access to your session's sockets | +| engine and helper (as you) | engine is a GUI-subsystem binary with no window; every child: `CREATE_NO_WINDOW` for console programs, `STARTF_USESHOWWINDOW` + `SW_HIDE` for GUI ones; PowerShell `-NoProfile -NonInteractive -WindowStyle Hidden` started without a console, never through `cmd /c start` | engine and helper are `LSUIElement` / background-only; apps opened with `open -g -j` or Launch Services with "don't activate"; scripts use `launch`, never `activate` | apps started without an activation token (`xdg-activation` on Wayland; no `_NET_WM_USER_TIME` on X11), so compositors refuse to focus them | +| automation adapters (§5 route 2) | COM servers created with `Visible = false` / `DisplayAlerts = false` and macros forced off; WinRT and Shell verbs that need no UI | Apple Events to a running app or a hidden launch; scripting dictionaries without `activate` | D-Bus calls, no window | +| browser work | the agents' browser always runs headless as the agent account; visible browsing uses your own browser (§6.5) | same | same | +| rungs 1-3 | UIA patterns and window messages work on windows that are not in front; the engine does not call `SetForegroundWindow`; rung 3 windows are on a hidden desktop you do not see | AX actions without `AXRaise`; `CGEventPostToPid` does not activate | AT-SPI actions without focus; rung 3 on its own display | + +**Watched while it runs.** While a non-visible batch or `host_run` runs, the engine watches for new shown top-level +windows and for foreground changes (Windows `SetWinEventHook` for `EVENT_OBJECT_SHOW` and `EVENT_SYSTEM_FOREGROUND`; +macOS `NSWorkspace` activation notifications plus the window list; X11 `MapNotify` and `_NET_ACTIVE_WINDOW`; Wayland: +per compositor, to verify). A window shown by a process the agent started: the engine hides it, gives the front back to +the window you had, logs it, and the step's result carries `visible_side_effect` with the program name. A program that +keeps showing windows when hidden is stopped and the step fails with `visible_side_effect`. Windows from your own +programs are never touched. + +**Tested.** A "no pop-in" test runs on each OS in fork CI (where a runner with a desktop session exists) and before an +OS is enabled: with a test window in front and focused, it runs every host tool, every adapter verb, every input rung +1-3, a set of console and GUI programs (including ones that try to show a window, a console program under Windows +Terminal as the default terminal, PowerShell, a script interpreter) and every saved test recipe, while the same watcher +records events. Any shown window, focus change or cursor movement not caused by a step marked visible fails the test. +Each new adapter verb or bridge must pass it before it ships. + +### 6.9 Later options for an agent screen of its own (measure first) + +**Owner decision (2026-11-14):** no own screen for the agent on Windows or macOS in v1, and no trial of these now. The +agent works through commands, its hidden browser, your apps with your OK, and your linked machines. If that proves too +little, these are the options, each with what it must prove before it is built: + +| Option | What it must prove first | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Windows Sandbox** (Pro and higher) | it starts in seconds with nobody signed in and next to your session; the agent's apps run and can be driven by UI Automation inside it; it has no way out to your files, clipboard or network beyond what the config maps (mapped folders, networking off or limited by the same floor as §3.5); its GPU and memory use is acceptable | +| **A hidden desktop of the agent account** (Home and Pro) | a desktop in a non-interactive window station of the agent account (session 0, no GPU) runs ordinary apps and answers UI Automation; nothing there can reach your session's clipboard, windows or input; it survives a reboot with nobody signed in | +| **A spare PC or VM that is the agent's own**, linked like any other machine | the same host tools and rules work there with no change; its link and network are limited like any linked machine (§3.5, §10); it is cheap enough to keep running; your files reach it only through what you name | + +None of these changes the rules for acting as you on your own screen (§4.4, §6.7). + +## 7. Batches, waits and recipes + +### 7.1 The step list + +`host_do` takes a JSON list of steps (§6.3), not a programming language, so the engine can check the whole list against +the policy before it starts and the log shows exactly what ran. Each step can name an `id`, a `timeout`, an +`onFail` (`stop` by default, `continue`, or `goto` a later step), an `expect` (§6.4), a `maxRung`, and can bind a +result to a name that later steps use (`${found.id}`). There are no loops beyond `wait` and a bounded `repeat` (count +cap). + +**Conditions** for `wait`, `assert`, `expect` and `goto`: an element exists / is gone / has a value or state (by id or +selector); a window with a title or app is open / in front / closed; a process runs / exits; a file exists / changed; +the clipboard matches; a bridge read verb's result matches a JSON path; any of / all of these. + +**Result:** per step `ok` / `failed` / `refused` / `stale` / `unknown` / `needs_visible` / `interrupted_by_user` / +`skipped`, its outputs, the rung used, its time; then a diff of the touched windows since the batch started (or since +the snapshot id given), and crops only if asked. + +Example (placeholders): export a report from an app and wait for the file, in one call: + +```json +[ + { "app": "open", "app_id": "", "path": "/report.src" }, + { "wait": { "window": { "title~": "report" } }, "timeout": 5000 }, + { "el": "invoke", "selector": "role=menuitem name=\"Export…\"" }, + { "wait": { "element": "role=dialog name~\"Export\"" }, "timeout": 3000 }, + { + "el": "set_value", + "selector": "role=edit name=\"File name\"", + "value": "/report.pdf" + }, + { "el": "invoke", "selector": "role=button name=\"Save\"" }, + { "wait": { "file": "/report.pdf" }, "timeout": 10000 } +] +``` + +### 7.2 Idempotency + +Every `host_do` carries an idempotency key. The engine journals each step (planned, started, done, with its result) +before and after it runs, with an fsync, so the journal survives a power cut. A retry with the same key returns the +journalled result and never runs a done step again; a step that was started but not confirmed is reported as `unknown` +and is not re-sent (idea from agentcast, #28050). The agent decides from a fresh diff. + +**Key tombstones (correctness review).** Journal entries are kept 24 hours (§15), but each key leaves a **tombstone** +(the key and a hash of its step list) for 30 days (setting, always longer than any retry window). A call with a known +key and a **different** step list is refused (`key_mismatch`), never answered with the old result. A call with a key +whose entry is gone but whose tombstone remains is refused (`key_expired`) instead of running the batch again. A new +key with no tombstone runs normally. The guard's log carries each key and its step-list hash, so if the tombstone +store is lost the guard rebuilds the tombstones from the log of the last 30 days at its next start, before it accepts +a batch (correctness minor). + +### 7.3 Recipes + +A recipe is a **named, parameterised step list** saved on the computer, for example `.export-pdf(path)`. + +- **Save.** `host_recipe save` (name, description, parameters, steps, the app and its version, the rung per step). The + guard stores it in the protected store, versioned, with a content hash; saving the same content again is a no-op. +- **Run.** `host_recipe run` or a `recipe` step: the same checks as a fresh batch, at engine speed, one network hop. + Recipes use selectors, not element ids. +- **Repair.** When a step fails because the app changed, the run stops there and returns the diff; the recipe is marked + `needs repair` until a new version passes. +- **Review.** Recipes show in the tray with their steps; you can delete or pin them. A recipe never widens the policy. +- Recipes are per computer in v1. + +### 7.4 Speculative parallel batches + +**Owner decision (2026-11-13):** when the agent is unsure which of two or more ways works (which command finds the +file, which selector the app uses, which build flag is right), it may run the alternatives **at the same time** and +keep the one that works. + +**Shape.** `host_do { alternatives: [steps, steps, …], pick, check }`, one idempotency key for the whole call. + +**Private copies only (design choice, owner left it to us).** Every alternative works on its own private copy; the r2 +option of writing to real folders and restoring the losers from a snapshot is dropped. Why: restoring real folders +races with your own edits (the same problem as lens M9), depends on snapshots that are now small and at most 24 hours +old, and needs disjoint-path bookkeeping that a crash can leave half done. Large folders do not need a full copy: each +alternative sees the real folders **read-only** plus a private writable layer (Linux overlayfs; macOS APFS `clonefile` +of only the declared write paths; Windows a copy of only the declared write paths, block clone on ReFS/Dev Drive). + +**Which steps may run speculatively.** The engine classes every step before anything starts and refuses the call if +any alternative has a step outside these classes: + +1. **read-only:** `observe`, `wait`, `assert`, element and bridge **read** verbs, `app` read verbs, file reads, and + `run` steps (which see the read-only view); +2. **writes into the alternative's own private layer:** its `run` and file steps write only there. + +**Never speculative:** `input` on any rung, element and bridge **act** verbs, `window` and `clipboard` changes, `app` +act verbs, `host_ask`, `host_named`, approved scripts, and anything that sends over the network. + +**The OS enforces the sandbox (lens M2),** not the agent's claim: + +- Linux: a mount namespace with the real folders bound read-only and only the private layer writable; a fresh network + namespace with no interfaces; no access to `/run/user`, abstract sockets or any host socket. +- macOS: a sandbox profile with file-write limited to the private layer, network denied, file-read denied on every + secret-pattern path, and no Mach or Unix-socket IPC to the engine, the broker or T3; each run under a per-slot hidden + user from a small pool, so cleanup ends every process of that user (process groups do not hold `setsid` daemons). + The slot users are in the agent group (§3), so every deny entry of the agent account holds for them too (lens N6). +- Windows: one AppContainer **per alternative**, with no OS capabilities (no network, no device access); its only + capability is one fixed **fork-owned capability SID**, which grants read access to the real folders and to the + agent account's own toolchains (compilers, package caches under its home) and nothing else (never `ALL APPLICATION + PACKAGES`, which would open your folders to every packaged app; lens p10); write access to a private layer is + granted only to that alternative's own AppContainer SID, so one alternative cannot write another's layer + (correctness review). The engine's and broker's pipes carry no AppContainer ACE, so they refuse it. The read ACEs + are applied with the §3.4 walk and are on the uninstall removal list (§15; lens n8). Each alternative's AppContainer + profile is ledgered before it is made; the guard deletes every ledgered profile left by a crash at its next start + (correctness minor). +- The private layer is made **by the agent account**, so denied files cannot be read and are skipped, and copies of + secret-pattern files are never made. A denied `.env` never becomes readable in a copy. +- Speculative processes cannot reach the engine, the T3 server or any local socket (also because the engine admits only + the host account, §6.1). + +**Sized from the task and the free hardware (owner decision 2026-11-13), no fixed numbers.** Each alternative's need +(peak memory, CPU time, disk I/O) comes from that command's history on this computer, or a default estimate the first +time. The engine starts as many alternatives at once as fit into the free hardware measured by the activity service +(§11) minus its buffer, and queues the rest within the same call. If not even one fits, they run one after another. +This sizes parallelism only; it never delays work you asked for (no activity gate). + +**Picking the winner and comparing results.** + +- `pick: "first"` (default): the first alternative whose steps all end `ok` **and** whose `check` holds wins. `check` + is a §7.1 condition evaluated against that alternative's results and private layer. With no `check`, "all steps ok" + is the test. Ties go to the earlier one in the list. +- `pick: "all"`: every alternative runs to its end or the batch timeout; the result lists each with its outcome, a short + redacted output digest and the diff of its layer, so the agent compares them in one look. Layers are kept until the + agent applies one (`host_do { apply: }`) or 10 minutes pass. An `apply` on a layer that is gone + (expired, or deleted by a restart, §12.3) returns `layer_gone` and changes nothing. +- The result says which alternatives ran, which won, why each loser lost (`failed`, `check_false`, `cancelled`, + `timeout`), and the time each took. + +**Cancelling and cleaning up losers.** As soon as a winner is known (`first`) or the agent applies one (`all`): each +loser's processes are ended (job object / cgroup / per-slot user: a polite stop, then a hard kill after 2 s); its +private layer is deleted; its waits, watchers and cached trees are dropped; it is logged as `cancelled_speculative`. + +**Applying the winner (lens N6).** Read-only winners just return their results. A winner's changes are applied to the +real folders as one write burst **by the agent account**, through the broker, so every OS deny entry holds: a layer +that contains a `.git/hooks/post-checkout`, a new `.vscode/tasks.json` or a file named `.env` fails to apply there just +as a direct write would. The applier also refuses, before it writes anything, every path that matches a secret pattern +or an auto-run path, so a refused apply never leaves half a layer. The burst takes no extra snapshot (§4.3 rule 6: the +pre-images below are its undo); each +file compare-and-swapped against the hash it had when the layer was made, through the link-safe operations of §3.4. +If any real file changed meanwhile, nothing is applied and the result is `conflict` with the list. Every file is +written from a pre-image journalled first; if the burst stops part-way (a crash, or a re-apply after a crash that +meets a file you changed in the meantime), the files already written are **rolled back from their pre-images** and +the result is `conflict` with undo offered, so a winner is never left half applied (lens p7). + +**Overlay caveat (Linux).** overlayfs does not define what a reader sees when the lower (real) folders change while +the overlay is mounted. Where the filesystem has snapshots (btrfs, ZFS, LVM-thin) the lower layer is a read-only +snapshot taken at the start; elsewhere a run that reads a file you are editing may see torn data. The apply's +compare-and-swap still catches the final conflict (§14). + +## 8. Speed: budget, measurement, and why not classic computer use + +### 8.1 Latency budget + +Targets on a normal desktop PC, measured inside the engine and on the agent machine: + +| Item | p50 target | p95 target | Note | +| --------------------------------------------------------- | --------------------------------------------------------- | ---------- | ---------------------------------- | +| network hop, agent machine ↔ computer (tailnet, same LAN) | ≤ 5 ms | ≤ 20 ms | through a relay: tens of ms | +| batch overhead (policy check, journal, log) | ≤ 3 ms | ≤ 10 ms | per batch | +| window list + focus | ≤ 5 ms | ≤ 20 ms | | +| snapshot diff of a watched window (warm cache) | ≤ 15 ms | ≤ 60 ms | read from the event-fed cache | +| first snapshot of a window (cold) | ≤ 150 ms | ≤ 400 ms | one cached tree walk | +| rung 1 action dispatch (invoke, set value) | ≤ 10 ms | ≤ 40 ms | plus the app's own reaction | +| rung 2 window event | ≤ 1 ms | ≤ 5 ms | 1,000 characters ≤ 60 ms | +| rung 4 synthetic input event | ≤ 2 ms | ≤ 10 ms | 1,000 characters ≤ 300 ms, checked | +| step-down to the next rung (no effect seen) | ≤ 300 ms | ≤ 600 ms | once per app version (rung memory) | +| bridge verb, local | ≤ 5 ms | ≤ 30 ms | plus the bridge's own work | +| wait reacting to its condition | ≤ 30 ms | ≤ 60 ms | event-driven; 25 ms poll otherwise | +| crop + scale + encode screenshot (1280 px edge) | ≤ 40 ms | ≤ 100 ms | only when asked | +| observation size | diff ≤ 1.5k tokens typical; window snapshot cap 6k tokens | | screenshot about 1-1.5k tokens | + +**Task level:** the model turn (seconds) is the cost that matters. Target: a typical UI task in **1-3 model turns** +(observe, one batch, sometimes one fix), against 10-40 turns for classic computer use. + +### 8.2 How it is measured + +- **Engine micro-benchmarks** (no model): every item in §8.1 against stock OS apps per OS and test apps the fork ships + (a small accessibility-rich app, a canvas app with a test bridge, a test web page, a test app that accepts only real + input). They run in fork CI on each OS where a runner with a desktop session exists, nightly; a p95 more than 25 % + worse than the last baseline turns the job red. +- **Task benchmark** (with a model, run by hand or weekly, since it costs tokens): about 20 general tasks (change a + setting, fill and save a form, export a file from an app, find a value in a long list, a multi-page web flow, read and + act on a canvas app through its test bridge), each in three modes on the same computer, model and link: + 1. **native**: everything in §5; + 2. **tree only**: no adapters, bridges or recipes; + 3. **pixels**: screenshots and rung 4 input only, one action per model turn: the classic computer-use baseline. + Recorded per task: success, wall time, model turns, input and output tokens, the rungs used, and the engine's own + times. Results go in a table in the PR that ships the engine, and the targets above are adjusted from them. + +### 8.3 Why not classic computer use + +Classic computer use (Anthropic's computer-use toolset, Codex Computer Use, Cua-style drivers attached as one tool per +action) has the model act like a person: take a screenshot, pick a pixel, click, take another screenshot. Each action +costs a full model turn plus an image. In practice that is about **one action every 3-6 seconds** (owner's measure: +about 5 s), and a 20-action task is 20 turns, 1.5-2 minutes and roughly 20-30k image tokens, before any mistake. It +also takes your mouse for the whole time. + +The same task in this design: one observe (a text snapshot, a few thousand tokens), one `host_do` batch that the +computer runs in well under a second plus the app's own time, one check of the diff: **2-3 model turns, about 10-20 +seconds**, a fraction of the tokens, exact element targets instead of pixel guesses, and your mouse untouched unless +nothing else works. The §8.2 benchmark checks this estimate before the owner relies on it. + +## 9. Tools + +All take `environmentId` (the computer) and are logged with the calling project and thread (§4.3 rule 9). Read tools +work over a read-only link; the others need write access on the link and a non-plan caller (upstream checks both), and +a project grant. + +| Tool | Kind | What it does | Limits | +| ------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| `host_status` | read | online, paused or winding down, install verify, availability, policy hash, reach map, grants in force, snapshot and log state, link expiry, discovery (§6.2) | | +| `host_list`, `host_stat`, `host_read`, `host_search` | read | files in the reach map; a secret shows as name + `secret` only | 1 MB per read, 2,000 entries, search 20 s; refused while the deny pass is stale | +| `host_write`, `host_mkdir`, `host_move`, `host_delete` | write | in the reach map; compare-and-swap on the old hash for writes; pre-image kept | snapshot rule of §4.3 rule 6 | +| `host_run` | write | a program with arguments, a working folder in the reach map, a timeout, as the agent account through the broker | output 64 KB tail, redacted | +| `host_undo` | write | list changes since a time; restore the agent's own changes (§4.3 rule 6) | your changes: listed, restored only from the tray | +| `host_observe` | read | windows and focus; snapshots or diffs per named window from the best source; optional crops; clipboard if asked (§6.2, §6.7) | sensitive set refused | +| `host_do` | write | a step list run by the engine (§7), or speculative alternatives (§7.4) | sensitive set; high-risk grants; batch limits; take-over; `visible` flag (§6.8) | +| `host_recipe` | write | list, show, save, run, delete recipes (§7.3); list and show work over a read-only link | run = same checks as `host_do` | +| `host_ask` | write | the one batched ask of §4.2 (high-risk items, installs and admin commands included) | one open at a time; wear-down rules | +| `host_named` | write | a named command from the protected store, run as you from a frozen copy (§9.1) | | + +Eight tools (plus the file group). The file and run tools exist so agents on another machine need no full agent on the +computer. + +### 9.1 Pop-ups: only the batched ask + +The batched ask (§4.2) is the only dialog. Two of its item kinds carry a little more: + +1. **Installs.** The item names a package (winget / Homebrew / the distro's package manager), its source and size, and + for whom. On Allow the helper installs as **you**; Windows UAC or the OS password prompt follows when it is + machine-wide. Installing tools for the agent account only (into its own folders) needs no ask. +2. **Admin commands and other protected-area actions** (§4.4). The item shows the exact command, path or key and the + content or diff. The helper copies every file the command references into a protected folder and shows its hash or + content; on Allow the guard checks the hardened confirm (§4.2), an elevated run also shows the OS's own prompt, + and the **copy** runs, in a + protected working folder, so nothing can be swapped between the ask and the run (lens B1). Each Allow covers that + one action once. + +**Named commands and approved scripts** are entries in the protected store, run by the helper as you, with output +scrubbed of every secret value: things that need **your** secrets (a deploy with your SSH key) or your session. They +need no ask because you wrote or approved them, and they run from a frozen copy (§4.3 rule 4): a deploy from a repo +runs at the commit you approved; a new commit is one ask showing the commit and the diff since the last approved one. + +## 10. Network, auth and links + +- All traffic goes over the linked environment: upstream's auth, routes and revocation. Tailscale is the expected route + between machines; the computer's T3 service answers on loopback, LAN and tailnet only (upstream behaviour), and gives + loopback no special trust (§3.5). +- The agent account's outgoing network: §3.5. + +**Link expiry, what upstream does today** (upstream `main` 8cd8a3ca5a): + +- A peer link signs in with OAuth `authorization_code` against the other side's MCP OAuth + (`apps/server/src/peer/PeerLinks.ts:308`, token request at `:379`) and stores `expiresAt = now + expires_in` + (`PeerLinks.ts:536`). +- The token is an MCP-client session with a fixed 30-day lifetime (`apps/server/src/auth/EnvironmentAuth.ts:79`, + issued at `:1347`, called from `apps/server/src/auth/McpOAuth.ts:546`; `expires_in` at `McpOAuth.ts:564`). +- The MCP OAuth server supports only `authorization_code` (`McpOAuth.ts:97`) and refuses every other grant + (`McpOAuth.ts:510-511`): there is no refresh token and no renewal. +- After 30 days the link is `expired` (`PeerLinks.ts:593`, `:645-648`: "Link it again with a new pairing code"), and the + linking side checks its own stored expiry before every call (`apps/server/src/peer/PeerLinkRequests.ts:106-112`). +- Upstream issues and PRs: issue #33183 (open: sessions expire after a hard-coded 30 days; asks for a setting or sliding + renewal), issue #9884 (open: session lifetimes), PR #31755 (closed unmerged: a TTL setting for pairing-redeemed + sessions), PR #26757 (open: Tailscale identity sign-in for clients), PR #25024 (open: the desktop app renews its own + local session only). No open upstream PR adds renewal of peer links. + +**Owner decision (2026-11-13): links expire after 365 days and extend with one click; no refresh tokens.** You trust +your own computers; the goal is to keep **outside people** out, not to add friction between your machines. + +1. **Lifetime setting.** `peerLinkSessionDays` on the computer, for the MCP-client sessions it issues to peer links: + default **365 days**; a shorter or longer number of days is allowed, "never expires" is not. It replaces the fixed + constant at `EnvironmentAuth.ts:79` for peer-link sessions only, and the issued `expires_in` follows it, so the + linking side's own expiry check keeps working. **Lowering** the setting also cuts existing links to the new + lifetime, counted from their last extension (lens p8). Written as a small change upstream could take for #33183. + **The row's expiry decides (correctness A).** Upstream session tokens are signed and carry `exp`, and + `SessionStore.verify` refuses on the token's `exp` before it reads the session row. For peer-link sessions the fork + changes `verify` to check the **row's** `expiresAt` instead (the signature and every other check stay), so an + extension or a lowered lifetime takes effect at once without issuing a new token. A fork change to `SessionStore.ts` + (§2). +2. **Extending is one click, no pairing code**, from the tray or from any linked machine. On the computer, the tray's + **Extend links** sets every link to now + the lifetime, including links that expired in the last 30 days (older + ones need pairing again). From any of your already-linked machines, one click in Sharing & access (or `t3` link + extend for a named computer) asks over its live link; the computer extends that link, and, when the caller's link + has write access, any other link you name. The extension happens on the computer, in the same transaction that + checks revocation, so a revoke always wins over an extension in flight. **Only device-bound links extend:** a link + with no recorded device (made before device binding) is not extended and is not given host tools; the tray asks you + to pair it again (a stolen unbound token can therefore not bind itself to a new device). The tray and Sharing & + access show, for each link, when and **from which device** it was last extended (lens p2). + **The linking side learns of the extension (correctness review).** Upstream stores the expiry once at sign-in and + refuses its own calls after it. So every reply from the computer carries the link's current expiry, and the linking + side updates its stored `expiresAt` from it; and when a link looks expired locally, `usableLink` asks the computer + once (a small status call on that link) before dropping it, so a link extended in the tray works again without + pairing. Both are fork changes to `PeerLinks.ts` and `PeerLinkRequests.ts` (§2). **The status call (lens n6)** is + the only call that accepts an expired token, and only with a valid device proof; it returns the link's current + expiry and nothing else, and never extends. A linked machine cannot extend another device's link that has already + expired; only the tray on the computer can. +3. **Heads-up** 7 days before any link expires: tray on both machines, `host_status`, and a T3 notification on the + agent machine, each with the extend action. + +**Strong against outsiders:** + +- **Device binding.** At pairing the linking side makes a key pair, kept in its secret store, and **every** request is + signed with it (proof of possession, RFC 9449 DPoP), on every route. This reuses upstream's DPoP support + (`apps/server/src/auth/dpop.ts`: the proof check and the key thumbprint, recorded as the session's `jkt`) and its + replay markers (`apps/server/src/auth/replayMarkers.ts`) instead of a new scheme (correctness minor). +- **The body is signed too, and never sent in the clear (lens M11).** A DPoP proof signs the method, the URL, the + token hash, a time and an id, not the body. So for host tools the body's SHA-256 is a **claim inside the signed + proof** (a fork claim, `htd`; lens m2), not only the request's `Content-Digest` header, and the computer refuses a + request whose body does not match it. This check is new fork code in the server's `verifyRequestDpopProof` + (`apps/server/src/auth/dpop.ts`), next to the shared `verifyDpopProof`, which checks no extra claims (correctness r3 + minor 10). Host tools are + answered only over the tailnet or over HTTPS with the certificate pinned at pairing, **never over a plain HTTP LAN + route** (upstream allows those for paired tokens). Each proof's id goes into a replay cache that **survives a + restart**: upstream's replay markers, which are files in T3's secret directory (`replayMarkers.ts`; correctness + minor 6, lens m2), kept for longer than the proof's accepted age, so a captured proof cannot be sent again after a + restart. "Over the tailnet" is decided by the **interface the connection came in on** (the Tailscale interface), + never by the source address alone (lens m12). When a call arrives over the tailnet, the + computer also checks the caller's Tailscale node identity recorded at pairing (it asks its local Tailscale who the + connection comes from, the idea of #26757; asking `whois` from the host account, which has no interactive session, + is to verify on Windows). The node check is only an extra check, so the same link used over the LAN route is not a + false alarm (correctness review). A token without a valid signature, or over the tailnet from another node, is + refused, the link is marked suspect, and the tray alerts "a link was used from an unknown device", with Revoke. +- **No new devices without you.** Pairing codes stay upstream's short-lived single-use codes; a new device is always a + pairing you start, and the tray shows each new link. +- **Visible use.** The computer tracks each link's last use and shows it in the tray and Sharing & access; each + extension and each refused device is logged. +- **Revoke all** in the tray ends every link to and from the computer at once. + +**Light between your own machines:** no per-call confirmation, no token rotation, no re-pairing to extend. + +**Residual risk:** an infected machine of yours can use its links to your other machines until you press the kill +switch or **revoke all** on them. A **lost or stolen** device can extend its own links from itself, so expiry never +ends its access; only revoking it does (lens p2). + +## 11. Background runners: use the free hardware (runner side, not host tools) + +The PC also runs background work moved there from the server: CI jobs on fleet runners (#13), builds, other project +runners. **Owner decision (2026-11-13): no fixed levels or slot counts.** The runners measure what the hardware has +free while you play or work, keep a small safety buffer, and use the rest; when free resources drop, they shrink to +fit. Host tools do not use this to gate work (§4); speculative batches use the same measurement only to size their +parallelism (§7.4). + +**Measured** (the activity service, inside the helper in your session, every 2 s, excluding the runners' own use): + +| Resource | Windows | macOS | Linux | +| ------------- | --------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------- | +| memory free | available memory (PDH) and the low-memory resource notification | `host_statistics64`, memory pressure level | `MemAvailable`, PSI `/proc/pressure/memory` | +| CPU free | PDH processor time per core, minus runner processes | `host_processor_info`, minus runner processes | `/proc/stat`, PSI cpu, minus the runner slice | +| GPU free | PDH "GPU Engine" utilisation and "GPU Adapter Memory" | IOKit accelerator statistics | NVML, or `gpu_busy_percent` and VRAM in sysfs | +| disk I/O free | PDH disk queue length and latency | IOKit block storage statistics | PSI io, `/proc/diskstats` latency | +| your override | tray: "leave me X more" or "runners off" for 1 h, 4 h, until tomorrow | same | same | + +**Budget = free − buffer**, per resource. The buffer is small and adapts: default the larger of 2 GB and 10 % of +memory, one core, 10 % GPU and VRAM, and a disk latency ceiling; it grows for a while after you had a spike (your game +loading a new area), so short peaks do not hit a runner job. Example: a game leaves 15 GB of memory free, so the +runners may use about 13 GB. + +**Admitting work.** Each job kind has a need (peak memory, cores, GPU, disk I/O), from its own history on this +computer or a declared default. The dispatcher starts a job only when its need fits into the budget, and caps it at +that size (Windows job object memory and CPU rate limits, `BELOW_NORMAL`/EcoQoS; macOS `taskpolicy -b`; Linux +`memory.high`/`memory.max`, `cpu.max`, `cpu.weight`, `io.weight` on the runner slice). Runner processes always run at +low priority. For fleet runners (#13) the dispatcher's heartbeat reports `free` as the number of standard jobs that fit +the budget, so `auto` routing keeps working with no config change; the rest go to the server pool or GitHub-hosted +runners. + +**Shrinking to fit.** When the budget drops below what runs: + +1. **CPU, GPU or disk short:** lower the runners' weights and caps first (instant, nothing lost). +2. **Still short, or memory short:** pick jobs to remove until the rest fit, choosing per job the cheapest of: + **finish** (expected remaining time is short and the job fits throttled), **park** (the job can checkpoint: stop at + its next checkpoint, resume later here or elsewhere), or **move** (a pure job such as tests: cancel and re-run + elsewhere, losing only its work so far). Cost = CI time wasted plus agent usage it causes later; the job with the + least waste that frees enough goes first. +3. **Memory falls below the buffer fast** (risk of your game swapping): the newest runner job is moved at once, before + your work suffers. Memory caps stop a runner job from growing into your share. + +Growing back: more work is admitted when the larger budget has held for 2 minutes (setting), so it does not flap. If +the activity service stops reporting while someone is signed in, runners admit nothing new and the tray says why; with +nobody signed in, the budget is the whole machine minus the buffer. + +**How runners follow it.** The service writes `activity.json` (`at`, `free`, `buffer`, `budget`, `reasons`) where only +you write and the runner dispatcher (system) reads. Other runner kinds read the same file. Jobs with outside effects +(deploys, releases) never run on a personal PC. Suspending a CI job (freezing its processes) is not used: a frozen +runner loses its connection and the job fails; it is an option only for local jobs with no connection to keep. + +**Isolation of runner accounts on a personal PC (lens N10).** A CI job runs repository code, so runner slot accounts get +at least the agent account's walls, checked by the install verify (§3.3) and daily: + +- **Accounts:** one standard account per slot, in a runner group that is separate from the agent group; no admin, no + remote or local logon, no session of yours (no window station, display or session bus; §6.8's `host_run` row). +- **Files:** no reach map at all; each slot works in its own folder. The reach check (§3.3) runs as each slot account + and must find nothing of yours readable; the secret deny pass applies to the runner group too. +- **Network:** the always-denied floor of §3.5 (loopback except its own, LAN, tailnet, link-local, the computer's own + addresses, on-link prefixes). Unlike the agent account, a runner has the public internet, since a CI job needs it. +- **Disk:** a quota per slot account, and the cgroup or job-object caps of this section. +- **Persistence:** the agents' own-persistence denies of §3.1 apply to runner accounts too; slots are wiped after each + job (the fleet runners' one-job rule, #13). +- **Who may trigger:** on a personal PC, only jobs started by you (your pushes, your PRs, schedules of your repos) run; + jobs from other people's pull requests go to the server pool or GitHub-hosted runners. The **dispatcher on the PC + checks each job's event and actor itself** (from the job's signed context, against the repos and accounts in your + policy) before it runs it; routing on the server is not trusted for this (lens p11). + +## 12. Tray icon, wind down, kill switch and power loss + +One tray icon per computer, from the session helper, covering host tools and runner work: in the Windows notification +area (first run tells you how to pin it), the macOS menu bar, and the Linux status area (StatusNotifierItem / +AppIndicator; GNOME needs the AppIndicator extension, to verify). Built on Electron's `Tray`; upstream's desktop app has +no tray today. + +### 12.1 The menu + +- **Status:** host tools (idle, working, acting in _app_ on rung _n_, paused, winding down, safe to shut down), the + calling project and thread while acting, link expiry and last use, runners (jobs running, budget and why). +- **Pause / Resume**, and **Continue** after a take-over (§6.7, needs the T3 confirm, §4.2; the take-over pause covers the + whole computer). +- **Wind down** and **Shut down when safe** (wind down, then the OS shutdown). +- **Kill switch** (asks once: "stop every agent process on this computer now?"), and **Revoke all links**. +- **What the agent wrote and ran** (§4.3 rule 7), **held changes** to protected areas and quarantined auto-run paths + (Keep / Remove), agent-caused processes running as you (with End). +- **Extend links** (§10), with when and from which device each link was last extended; grants in force and remembered + answers (with Revoke), runner override (§11), open log, open policy, recipes, bridges waiting, install-verify warnings + (§3.3, including a UAC level below "Always notify"), network openings in force (§3.5). +- **Undo store** (§4.3 rule 6): space used against your disk budget, the 80 % warning, held or quarantined items older + than 24 hours, copies pruned early, and the last-resort refusal when it is on and the store is full. + +### 12.2 Wind down (owner decision 2026-11-13: no fixed timer) + +1. The guard writes `winding_down` (time and what was running) to the protected store. Nothing new starts: host tools + and new turns are refused with "winding down", runners take no new jobs. +2. Each running item goes to a safe point **as fast as possible without wasting work**, chosen per item by remaining + cost: a batch finishes its current step and releases input; a `host_run` job finishes if it is expected to end soon, + otherwise it is stopped (its changes stay, with undo); runner jobs finish, park at their next checkpoint or move + elsewhere, by the cost rule of §11; a take-over-style stop is never used where finishing is cheaper. A snapshot is + taken of folders changed since the last one. +3. The tray lists each item with its expected time and choice, and "kill it now" per item. When nothing is left it + shows **"Safe to shut down"** (and shuts down if you chose that). +4. Wind-down ends when you choose Resume, or at the **guard's next start** after a restart (with or without anyone + signed in), back to the state before it (unless "stay paused after restart" is ticked). At that start the guard + clears `winding_down` in the protected store **and** in the T3 server's and runners' state in one step, so no part + keeps refusing work (correctness minor: one moment, not "the next sign-in"). + +The **kill switch** stays the immediate stop (§4.3 rule 8). + +### 12.3 Power loss and hard reset (owner decision 2026-11-13) + +The computer may be switched off at any moment. Every part writes its state so that the next start recovers: + +- **Atomic and durable writes:** the protected store, recipes, journals, ledgers and state files are written to a temp + file, fsynced, then renamed; logs are fsynced per line. **Narrowing first (lens N8):** revoke, Deny, pause, the kill + switch and narrowing the reach map are durable (store and a narrowing record in the guard's log, both fsynced) + before the tray confirms them; a power cut can lose a narrowing only if the tray never said it was done. +- **No fallback that widens (lens N8):** when the store's signature fails, the guard does not simply load the last + good version, which could hold grants you revoked since, an auto-run unlock you withdrew or no pause. It loads that + version and **replays every narrowing record in its log since that version**. If it cannot (the log is damaged too), + it starts with **no grants beyond the built-in defaults and with pause on**, and the tray says so. +- **On the next start, in order:** the guard checks the protected store's signature (with the fallback above) and the + log tails (a half line gets a `truncated` marker); it removes every ledgered network opening and re-applies the stored exceptions (§3.5 order); it deletes leftover + speculative AppContainer profiles (§7.4) and retakes missing guard copies (§4.3 rule 6); it rebuilds lost + tombstones from its log (§7.2); **the guard** runs the full deny pass and the install verify (§3.3); only then it + starts the broker and the engine worker (§6.1); the engine drops held-input files from before the boot (the OS + reset input) and marks journalled started-but-unconfirmed steps `unknown`; speculative layers listed in the journal + are deleted; snapshots the OS did not finish are discarded; the runner dispatcher re-asks for every job in its ledger + and for jobs that were running on this computer (GitHub reports them failed with a lost runner); `winding_down` from + before the boot is cleared; "this task" grants are ended; a take-over pause stays (§6.7). +- **What is lost:** work inside apps that was not saved; agent file changes after the last snapshot that were not made + through T3's file tools (those have pre-images). Both are stated in the failure table (§15). + +## 13. Speed of the parts, and language + +- **Engine: one native binary per OS, in Rust** (recommendation). Speed first: the tree cache, event handlers, batches + and input need direct OS calls with no runtime start or bridge in between (UI Automation with cache requests, + AXObserver, AT-SPI over D-Bus, `SendInput`/`PostMessage`/`CGEvent`/libei). Rust has maintained bindings for all three + and one language serves every OS. Alternatives: C# (Windows-strong, weaker on macOS accessibility), Go (C interop + for COM and AppKit is more work), TypeScript in the Electron process (slowest for UIA and blocks the helper). Cost: a + Rust build per OS in fork CI. +- **Command broker and guard service:** small, per OS; Rust too, sharing the link-safe file operations of §3.4. +- **Helper, toolkit and core:** TypeScript inside T3 and the desktop app's main process. No new runtime to install. +- **Per call:** one round trip over the link, plus the engine's own time (§8.1). Today a link whose first address is + silent costs 10 s per call (upstream #35519), so the computer's saved routes list the tailnet address first. + +## 14. Threat model and what still leaks + +**Two kinds of control (audit tier label).** A **boundary** is enforced by the OS against code running as you or as +the agent: the agent and host accounts with their deny entries, the network floor, the OS admin prompt (on Linux only where the +build proves the polkit agent cannot be replaced, §4.2) and the protected store. A **tripwire** is best effort against code already running as you: the persistence watch and the +acting window, the agent-caused marks, the baseline diff, the confirm panel and the activity log. A gap in a boundary +is a major; a gap in a tripwire is a minor unless the tripwire itself hands out new rights. + +| Threat | What stops it | What is left | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Prompt injection from files, screens, bridges | the sensitive set, the high-risk ask, protected areas, untrusted marking, project grants and outside-input threads, indicator, take-over, the activity log, undo, the kill switch | **in any app it acts as you** (§4.4): it can send a message, and through a file dialog, a link or a launcher run any file as you, including one it wrote; that code can do what your account can outside the protected areas | +| Agent-caused code persists after the task (N1, N4) | protected areas held per action (OS and deny entries for system areas, including user-writable spots; watched always, held during an acting window and diffed per grant for yours); a held entry's processes ended; held changes neutralised in place (never copied, so a full area cannot leave one active) and the object restored from the guard's own copies; a clean-stop gap (a reboot with updates) is yours, an unclean one held only where the agent could write; real creator counted; marks from Task Scheduler, WMI, COM, Explorer shell-execute, `systemd --user` and `launchd` starts while a marked process lives or during a window; marks journalled and caught up after a guard restart; agent-group processes outside a job ended; kill switch ends agent-caused processes | a persistence place not on the built-in list, a changed program in a writable folder, or a change made through a process that escaped the agent-caused mark (for example COM into the running Explorer after the last marked process ended): shown only in the activity log, not ended by the kill switch | +| Files the agent wrote run as you (B1, N1) | approved runs use frozen copies and protected working folders; bridge hashes; auto-run paths denied, renames blocked, new ones quarantined; the activity log flags every run of an agent-written file | **open by owner decision:** you or the agent opening such a file through an app's UI runs it, with no OS warning; IDE build sync can run an agent-edited build file (N3d) | +| Planted links redirect a privileged write (B2) | link-safe operations (§3.4): no following, link count 1, final-path check, walks stop at links; the guard's own copy walker on Linux; the guard only takes away on places others can write, and gives run as the place's least-privileged writer (§3.4 audit rule 1) | | +| The agent widens its own policy (B3, N4) | protected store owned by the guard, unreadable to the agent group; the T3 confirm (real input only, bound to the item by a one-time nonce) for Continue and renewed asks; the hardened confirm, the OS's own admin prompt (pending owner, issue #28), for full rights, elevation and protected areas, checked by the guard itself; nothing running as you changes who confirms; widening after recent acting needs the T3 confirm | a forged ordinary answer while agent-caused code escaped the mark; it grants only what your account already has, and is listed in the tray | +| A command takes over T3 on the computer (M1, N2) | separate host and agent accounts; admin-owned install; engine admits only the host account; no outbound link; forwarded-tool allowlist on the computer; T3 terminal and provider features off there | delegated full agents stay off until they run through the broker | +| Speculative run escapes (M2, N6) | OS-enforced sandbox; no network or local IPC; layers made by the agent account; slot users in the agent group; one AppContainer per alternative with a fork capability SID for reads; winner applied by the agent account, secret and auto-run paths refused, rolled back from pre-images if it stops part-way | Linux overlayfs: a run may read torn data while you edit a file it reads, where no snapshot mounts the lower layer (the apply still catches the conflict) | +| Agent reaches the network (M3, N5) | no direct internet by default; only the services named in config on your linked machines (default none); per-goal ask, named hosts only through the guard's proxy (CONNECT host and SNI checked, every resolved address checked against the floor and pinned, CONNECT only, run as its own service identity whose only floor exception is your DNS servers on port 53); an always-denied floor including the computer's own addresses, on-link prefixes and every tailnet address (named services are separate allow rules for direct connections only); lookups only through the guard's resolver; base block persistent from boot; openings ledger cleared at start, pause and kill; service paths (UNC, WebDAV, IPP, PAC, BITS, WSL) denied and probed, failing closed; WebClient off and stopped for the whole computer (owner decision 2026-11-14); SMB and printers to internet addresses blocked computer-wide for the System process and the spooler (pending owner, issue #29), probed | after you open the internet for a goal: anything the agent account can read can leave during that goal; on Windows and macOS, DNS through the system resolver is a two-way channel until the filters of §3.5 are verified; domain fronting through an opened CDN host (the proxy sees only CONNECT host and SNI); on Windows, SMB paths and printers on your LAN and tailnet (never the internet once the computer-wide block holds; until then host tools stay off) if no per-account block holds and you accepted that residual by name; git over SSH to named hosts does not work | +| Secrets via clipboard, logs, outputs (M4) | concealed markers, restored clipboard, "for you" content shown, concealed journal copies encrypted, redaction on every path and record | a secret in an unknown format | +| Typing lands in the wrong window (M5) | the input ladder (rungs 1-3 need no focus); rung 4 focus lock, chunk checks, abort; any real input stops rung 4 and pauses | an app that changes its own focus inside a chunk of 8 events | +| An untrusted thread calls host tools (M6) | per-project grants (new projects off), outside-input threads read-only, caller shown and logged | a thread in a granted project that reads outside text without the flag | +| Stolen link (M7) | device binding (upstream DPoP key on every call, body digest signed, tailnet node as an extra check, replay cache that survives a restart, no plain HTTP route), only bound links extend or get host tools, unknown-device alert, revoke all, last use and "extended by" shown, 365-day expiry, lowering the lifetime cuts existing links | an infected own machine can use its links until kill switch / revoke all; a lost device can extend its own links, so only revoke ends it | +| State fails open (M8, N8) | fail-closed rows in §15; install verify gates host tools, run by the guard at start; heartbeat; a lost guard channel pauses acting; narrowing durable before confirmed; fallback replays narrowing or starts paused; uninstall rows | | +| Agent undo wipes your work (M9) | writer recorded; agent undo restores only agent changes with matching hash | changes whose writer the OS cannot tell count as yours and are not undone by the agent | +| Browser with your rights (M10) | the agents' browser headless as the agent account, driven from the host account's T3, its replies untrusted, typed CDP verbs only; no visible agents' browser | browsing in your own browser acts with your logins (§4.4) | +| Input below the OS's checks (N7) | neither account signs in interactively (batch logon only); task and WMI starts, every DCOM AppID that admits the agent token (computed walk, measured allow list) and Secondary Logon denied to the agent group; no `uinput`; the confirm panel counts only physical input, excluded from capture, with no cursor clip or warp | | +| Hostile CI job on your PC (N10) | runner accounts: own group, no reach map, network floor, quota, no session, only your own jobs | what the public internet gives a job you started | +| Elevation without a prompt | setup sets UAC to "Always notify" with the secure desktop (owner answer 4), sudo timestamps cleared, no `NOPASSWD`, a warning for the `docker`, `lxd`, `incus` and `libvirt` groups and loose polkit rules; the hardened confirm (the OS admin prompt) before an elevated run, then the OS prompt itself when it runs; for that prompt, `AuthorizationExternalForm` / polkit checked by the guard | if you lower UAC and use an administrator account, known auto-elevation tricks defeat the guard-checked consent, the protected store and every protected area (warned); root inside your WSL distros (`wsl -u root`); on Linux with X11, code running as you can read the password you type into the polkit agent (Wayland closes it); on Linux, code running as you can replace the polkit agent, so the hardened confirm counts there only where the build proves it cannot, and otherwise those items are refused, as on X11 | +| Runs in protected areas (owner answer 1) | running an agent-placed file, anything elevated, or anything that registers persistence needs your OK per action; blocked before any code runs: Windows Deny `FILE_EXECUTE` (and read, for scripts) on agent-placed files, macOS and Linux open and exec permission events; host tools off while the guard does not answer | on Windows, about 1 s between the write and the deny entry (driver only if measurements show the need) | +| Take-over is ignored | the pause covers on-screen acting on the whole computer; Continue and a renewed ask need the T3 confirm: only the panel's own raw input (no accessibility actions, no window messages), bound to the item by a one-time nonce, engine stopped and injected input dropped while it is open, no overlapping window, no cursor clip or warp, excluded from capture, asks rate-limited per task and per computer | remote-control and on-screen keyboard input is refused (Continue waits until you are at the computer; the remote confirm is deferred, §17); on X11 no on-screen acting in v1; Remote Desktop input, the panel's restricted token and the macOS hardware check are to verify | +| Reading your other files | reach map, sensitive set, reach check | folders every user may read that you chose to keep; system areas (readable by design) | +| Secrets inside the reach map and readable system areas | built-in patterns, deny entries, fail-closed deny pass over the reach map and the readable system areas the reach check finds, watcher | a secret in an unknown format or ordinary file name; on Windows, a read in about the first second after you create a matching file (the holder is then ended) | +| Damage to your folders | snapshots and pre-images in an undo store sized from measured need within your disk budget, pruning only accepted goals, 80 % warning, last-resort refusal; writer log; `host_undo` | changes made by commands between snapshots; on Linux without btrfs, ZFS or LVM-thin, no undo of command changes (only T3's own file tools keep pre-images); macOS snapshot limits (§4.3 rule 6); changes inside apps | + +**What still leaks, plainly:** files in the reach map, readable system areas, and what the apps the agent uses show +(trees, values, screenshots) reach the model provider through the agent machine, and the internet when you open it for +a goal. Process names and redacted window titles of all apps. Data read goes into transcripts. **Anything your account +can read can leave once agent-caused code runs as you**, because that code has your network, not the agent account's. + +### 14.1 Owner scope vs lens + +Where a lens fix would contradict the owner's scope (A: goal-driven, whole computer except sensitive things, one +batched ask), A is kept and the hole is closed another way, or the owner accepts it and it is said openly. + +| Finding | Lens default not taken | Why | What closes the hole instead | Residual risk | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| N1 | file dialogs limited to typing a path; full-rights class by ability with an exec backstop; Mark-of-the-Web / quarantine flag on agent-written files | owner decision 2026-11-13: option A only, acting in the UI is acting as you, no marking or ask-before-open | said openly (§4.4); protected areas per action, OS-enforced where possible; the activity log of every file written and run; launchers and IDE terminals on the full-rights list; kill switch ends agent-caused processes; held entries' processes ended; Task Scheduler, WMI, COM, `systemd --user` and `launchd` starts during a window marked; user-writable system spots denied to the agent group and watched for you | agent-caused code runs as you and can do what your account can outside the protected areas, until the kill switch, and a process that escaped the mark is not ended by it | +| B3c | full-user-rights apps never get acting from a default | A: one batched ask, not per-app acknowledgements in the tray | high-risk list; the T3 confirm or the hardened confirm (the OS admin prompt), bound to the item by a one-time nonce and checked by the guard (owner 2026-11-14: no OS prompt for Continue and renewed asks); scope "this task"; widening needs that consent while agent-caused code runs as you; persistence baseline diff per grant | during a grant the agent has your user rights, including your profile's secrets; after it, whatever escaped the persistence watch (§4.4 "What is left") | +| m5 | unknown apps hidden, opt-in per app | A: whole computer by default | sensitive set refused, relevance-scoped observation, redacted titles, untrusted marking | what a used app shows (mail, chat) reaches the provider | +| B1a | open files only in apps named in the policy | A: no per-app list | macros forced off in the engine's own automation; protected working folders; the activity log | a data file that exploits an app's parser; any file run through the UI (N1) | +| r2 §6.5 | acting and synthetic input off until enabled | A and the owner's input decision | the input ladder: real input only last, `visible: true`, indicator, take-over that pauses until you continue | rung 4 still uses your mouse when nothing else works | +| M4 | clipboard read and write off by default | A: the clipboard is part of the computer | concealed markers refused, redaction, writes are acting and put back after the batch | a password copied from a plain app without markers is readable | +| M6 | host tools off for every thread until granted | owner: your own machines and projects are trusted, no friction between them | one toggle per project, off for new projects; outside-input threads read-only; caller logged and shown | a thread in a granted project reading outside text without the flag | +| M7 | refresh-token rotation, reuse detection, absolute lifetime | owner chose 365 days with one-click extension | device binding, only bound links extend, unknown-device alert, last use shown, revoke all | an infected own machine can use its links until kill switch / revoke all | +| B1e, N3d | package manifests, build files and other code read-only to the agent; or IDE auto-sync required off | agent work is code; A | only auto-run paths are denied, with rename blocks and quarantine; IDE sync settings warned at install; agent writes to build files flagged in the activity log | an IDE that syncs build files on change runs an agent edit as you with no click; code you choose to run (a test) may contain agent changes | +| m4 | bridge verbs allowed one at a time | A: one batched ask | the ask lists every verb with its own tick box; changed bridge files go back to waiting | "Allow" with all boxes ticked grants every verb | + +**Owner decisions changed by a blocker:** B3 (round 2) added the OS's own consent to ask items that grant full user +rights or elevation; the owner kept it in round 3 and it now also covers protected-area actions, checked by the guard +itself (N4); the owner then moved Continue and renewed asks to the T3 confirm with no OS prompt (2026-11-14), and the +OS consent left for rare actions is bound to the item (M8); the owner's answer 1 of 2026-11-14 (§17) moves those +rare actions to the hardened confirm, which in v1 is the OS's own admin prompt (issue #28). Blocker B1 (round 4, Remote Desktop redirection) fell away +with the owner's decision of 2026-11-14 to drop the agent's own Remote Desktop session. Blocker N1 (round 3) did not +change an owner decision: the owner chose to keep A and say it openly, and +added the protected areas and the activity log. Everything else from earlier rounds stays (speed first, invisible by +default, speculative batches, tray and wind down, runner-side activity, goal-driven scope, the batched ask, the input +ladder), changed only where the owner changed it (no direct internet for the agent account, take-over pause with no +idle timer, 365-day links, default folders, no visible agents' browser). + +## 15. Failure table (state the feature changes) + +What happens when it runs again, stops half-way, or meets old or empty state: + +| Step that writes | Crash or stop half-way | Run again | Old / empty / half-written state | Test | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| install: create the host and agent accounts and the service identity | one or both exist, nothing else | finds them, keeps them, re-checks groups (refuses if any is an admin) | an account or identity of that name that is an admin or in a remote group: refused; an old install's proxy or resolver running as the system: stopped and removed before `t3-net` starts (sec n4) | fake OS layer | +| install: reach map grants | some folders granted | re-applies all through link-safe walks (idempotent); removes grants for folders no longer listed | a folder deleted since: reported, skipped; a planted link in a folder: not followed, reported | fake ACL layer + link fixture | +| install: services, guard | some installed, not started; the broker and the worker are not installed as tasks, the guard starts them | replaced with the same definitions | an upstream "as me" task for the same T3 home: refused with the reason | fake OS layer | +| **agent account password** (Windows) | set once at install: the guard writes the random password to its own secret store, readable only by the guard (fsynced), **before** it sets it on the account; a crash between the two leaves the store ahead | at start the guard tries a batch logon with the stored password; if it fails it sets the stored password on the account again (it runs as SYSTEM); nothing is rotated; "password never expires" is checked and set again if missing | guard store lost or restored from an old image: a new random password is set at the next start; the agent account's DPAPI data (the agents' browser cookies) is lost and its sites sign in again | power cut between the two writes; reboot with nobody signed in | +| **broker and engine worker start** (guard) | both run in the guard's agent job; the guard creates the broker's pipe or socket and the agent window station, so a guard crash ends them; nothing is left running as the agent account | the guard starts them only after the base block, the opening cleanup, the deny pass and the verify (§12.3); starting again while one runs: the old one is ended first | a broker or worker from before the crash (Linux unit, leftover process): ended; any agent-group process outside a guard or broker job: ended and logged; reboot with nobody signed in: both start, host tools come on | kill the guard mid-start; reboot with nobody signed in | +| **host T3 task** (Windows, S4U) | registered at install with no stored password; a half registration is redone on the next install run | registering again: replaces the same task | an old system image or a reset password: the task still starts (S4U holds no password); a task found storing a password fails the verify and is re-registered as S4U by the next install run (sec n6) | reboot with nobody signed in after a password reset | +| **up-front denies: task, WMI, DCOM** (§3.1) | each object's original value is written to the ledger **once**, before the first change, and never overwritten; the guard only adds its own entries; a half-applied set fails the verify, host tools stay off | applying again: no-op; a second install run keeps the first ledger record | a Windows update that reset one: the daily verify finds it, host tools go off until the guard re-added its entries; a change Windows or another installer made since: stays; a new AppID that admits the agent token (an app install): the change notification re-walks at once and the broker starts nothing new until done; a task an agent account still made: deleted, its processes ended, acting paused | probe each route as the agent account; reset one descriptor by hand | +| **WebClient off** (§3.5) | the old start type is written to the ledger once, then the start type is set to Disabled, then the running service is stopped; a half-applied change fails the verify, host tools stay off | applying again: no-op; the ledger keeps the first original | re-enabled or started by hand or by an update: the daily verify finds it, host tools go off until the guard disabled and stopped it again; already Disabled before install: recorded as Disabled, so uninstall leaves it so; uninstall puts the old start type back only if it is still the guard's value | WebDAV probe; enable by hand, then the daily verify; uninstall after a hand change | +| **SMB and printers to the internet blocked** (§3.5, issue #29) | the computer-wide persistent WFP filters (System process and spooler) are written to the ledger before they are added; the SMB-over-QUIC client setting, where it is turned off, ledgered once; a half-applied set fails the probes, host tools stay off | adding again: no-op | a Windows update that removed a filter or turned SMB over QUIC back on: the daily probe finds it, host tools go off until re-applied; uninstall removes only the guard's filters and puts the QUIC setting back only if it is still the guard's value | UNC to a public test address over 445, an alternate port and QUIC, and an internet IPP URL refused; a LAN share and printer still work | +| **install verify half-applied** | host tools stay off; `host_status` names the failed check | re-runs every check; tools come on only when all pass | a later change (account added to a group, rule deleted): next quick or daily check turns tools off | verify fakes per check | +| **secret deny pass** (guard) | file tools stay off, the broker starts no agent-account process, **and every running agent-group process is stopped** (Windows: job capped, then each member suspended; Linux: cgroup frozen; macOS: ended) until a full pass completes | a full pass is idempotent | files created while the guard was down or during a watcher overflow: covered by the pass on start / after overflow; reads and agent-account starts refused while the pass is stale; agent-group processes left frozen or running from before a guard crash: ended at start; the daily re-apply freezes nothing | fake FS + clock; watcher overflow then `host_run cat .env` refused; a process already running when the secret appears is frozen before it can read it; an agent flooding the watcher stops all agent work on this computer and nothing of yours; a child started during the suspend walk is refused by the job cap; on Windows a handle opened before the deny is ended with its process | +| reach map: folder removed | grants removed for some files | removal finishes; agent processes with handles under it are ended first | a process that held a handle across the change: ended, logged | handle fixture | +| ask answer (guard) | answer written atomically or not at all; not written = denied | the same answer again is a no-op | a "this task" grant from before a restart: ended; an unanswered ask after a restart: denied | fake guard | +| **protected-area change held** (§4.4) | blocked, or neutralised in place (a ledger line first, then the rename into the guard's folder on the same volume, the value moved under the guard's key, or the entry disabled) and the existing object restored from the guard's own copy, as you (§3.4 audit rule 1); the hold is in the store | Keep or Remove twice: the second is a no-op; a neutralise or restore interrupted by a crash is finished on start from the ledger | a held change whose entry you since changed by hand: shown with both versions, nothing applied without you; an existing object with no guard copy (first start, upgrade): retaken from current state before agent work, marked unverified and shown once, never reverted to without your OK; a reboot after a clean guard stop: changes during it are yours (Windows updates go through); after an unclean gap: reverted only for objects the agent could write, elevation-only objects shown for review; an existing object is never deleted; your own app's update to a system spot that is not a persistence point: not held; no token for you (nobody signed in, no S4U or `setfsuid`): the object stays neutralised and the restore waits for your sign-in, shown in the tray | fake watcher per OS; held change to an existing Run value and shell profile; a reboot with updates after a clean stop holds nothing; a crash, then a service change by TrustedInstaller: shown, not reverted | +| **guard copies of watched objects** | each copy is written temp + rename into the reserved area; a half-written copy is discarded | refreshing a copy that did not change: no-op; never refreshed from an unknown writer or while a lost-events report or guard-down gap is open | copies from before a restart: kept, compared with the current object at start; a change by an agent-caused writer since: held; a change by an unknown writer during an unclean gap: held as the agent's where the agent could write, shown with Keep or Remove; a held change never needs room (neutralised in place), so nothing held stays active; a copy over the size cap: not taken, the object reverts from a snapshot; reserved area full: objects without a copy treated the same way, new agent work refused | fake FS + reserved area full, with a 100 MB held change next to a small Run value change (both neutralised, neither active) | +| **deny-execute on an agent-placed file** (§4.4) | the entry is journalled before it is set; a file recorded but not yet denied gets it at start | setting it again: no-op; Keep removes it once | a file kept before this rule: unchanged (you kept it); guard not answering permission events: host tools and acting off | DLL load into a test service, a script through an interpreter and a renamed copy with no extension refused; on Linux and macOS the file stays unrunnable (root, mode 000) with the guard stopped | +| **auto-run path quarantined** | the rename into the guard's folder on the same volume is link-safe and journalled; the tray item appears after the rename | Keep restores it with your denies applied; repeated Keep or Remove: no-op | a quarantined path whose folder is gone: Keep refused with the reason; "make it mine" on a repo whose `.git` you already own: no-op | fake FS + writer fixture | +| **writable-spot deny entries** (§4.4) | each deny entry is journalled before it is set; the verify re-checks all | setting an entry that is already there: no-op | a new user-writable spot (an installer since): found by the next reach check, denied, logged; host tools stay off until it is denied | fake ACL layer + reach fixture | +| **held item older than 24 h** | the item stays neutralised in the guard's folder; never pruned | reminders in the tray and the next ask, not repeated more than daily | store full: the hold stays (it needs no room), the last-resort refusal applies to new agent work; you remove or keep it | fake clock | +| **marks and baselines** | each mark (process id + start time) and each grant baseline is journalled before it counts; the acting window is read from the batch journal and grant records (end + 2 minutes), and a stop inside it counts as unclean | re-attaching to a marked process twice: no-op | after a guard restart: still-running marked processes re-attached, and processes they started while the guard was down marked from the process tree; a reused process id with another start time is not marked; a grant with no end: diffed against its baseline, or against everything newer than its start if the baseline is missing | fake process events + restart | +| **forwarded tool outside the allowlist** | refused on the computer before anything runs; logged | refused again | an older agent machine without the allowlist: still refused on the computer | toolkit test | +| **network floor on address change** | the agent group's network is closed while the rule update is pending | the update is idempotent | a new global IPv6 address or on-link prefix: in the floor before the next agent connection | fake interface events | +| **network openings** | each opening (goal, service or host, address set, WFP dynamic-session filter or nft set) is written to the ledger before it is applied | opening the same thing again: no-op | after a crash or power cut: every opening in the ledger and every leftover filter is removed at start (dynamic WFP filters vanish with the guard's session anyway), then the stored "this project" and "always" exceptions are re-applied, then the broker starts; the base block is persistent from boot, so nothing is open in between; pause and kill remove openings too | fake WFP / nft layer + crash | +| **proxy connection** (§3.5) | the proxy checks each resolved address against the floor before it connects and pins it; a proxy crash drops its connections, nothing opens | a new connection resolves and checks again | a DNS answer that changed since the ask (rebinding): checked again, refused if in the floor; the `t3-net` identity's own sockets are under the floor except your DNS servers on port 53 | an opened name that resolves to 127.0.0.1, 169.254.169.254, a LAN address, a tailnet address and a linked machine's address is refused; an absolute-form HTTP request is refused | +| protected store edit | temp + atomic rename by the guard | new store or old one, never half | edited outside the guard or damaged (signature fails): the last good version plus every narrowing record from the guard's log since it; if those cannot be replayed, built-in defaults with pause on and a tray note; you confirm an outside edit with the hardened confirm; unreadable: all write and acting tools refused | signature test + narrowing replay | +| `host_write`, `host_delete` | temp beside target, target unchanged; pre-image saved first | same content: OK; other content: compare-and-swap refuses | target changed since read: refused; pre-images that would not fit the undo store's cap: refused with the size | fake FS | +| **snapshot** | snapshot taken or not; log line says which; an unfinished OS snapshot is discarded on start | a request within 30 minutes of the last reuses it; the wind-down snapshot reuses one with no agent write after it; a winner apply takes none (journalled pre-images); at the daily cap the newest is reused and logged, work goes on | store at 80 % of the disk budget: tray warning; full: first prune copies of accepted or finished goals outside the undo window, then grow within the budget; still full: agent writes and runs **refused** (last resort, on by default), tray red with the reason; held and quarantined items are never pruned; Linux without btrfs, ZFS or LVM-thin: no snapshots, `undo: file tools only` (journal pre-images); macOS or Windows deleting snapshots itself: tray warning | fake snapshot store + fake budget; macOS snapshot vanishing | +| `host_undo` (agent) | some files restored, each through link-safe writes | re-run restores the rest; already-restored files compare equal | a file you changed since: listed, not restored; writer unknown: treated as yours; snapshot expired: refused with the oldest time | writer fixture | +| log line | line written before the action and after; a call whose log write fails is refused | the guard's countersign continues from the last good head | half line at the end: `truncated` marker, chain continues; a T3 log head that differs from the countersigned one: tray alert | log fixture | +| `host_do` batch | journal (fsynced) says which steps are done; started-but-unconfirmed is `unknown` | same key: journalled results, no step re-runs; a new key is a new batch | journal from before a restart: kept 24 h for retries, then dropped; empty step list: `ok`, nothing run | fake engine back end | +| **journal tombstones** (§7.2) | the tombstone (key + step-list hash) is written with the first journal entry | same key, same steps: journalled result; different steps: `key_mismatch`; entry gone, tombstone kept: `key_expired` | tombstones older than 30 days: dropped; a lost or damaged tombstone store: rebuilt from the guard's log at its start, before any batch is accepted; a key whose tombstone exists without an entry: `key_expired`; a new key with no tombstone: runs | fake clock + journal | +| **input rung step-down** | the rung tried and its result are journalled before the next rung is used | same key: no rung is re-tried; an unsafe-to-repeat action with an unclear check returns `unknown`, never a second apply | rung memory from an older app version: tried first, steps down once, memory updated | fake app per rung | +| **focus change mid-burst (rung 4)** | burst aborted between chunks, held input released, `stale` with the chunks sent | the agent re-plans from a fresh diff; never resumes the burst by itself | the target window gone: `stale` | focus-steal test per OS | +| **you grab the mouse/keyboard mid-step** | step stops, all held keys and buttons released, queued steps dropped, `interrupted_by_user` with what ran and a diff; the take-over pause is written to the store | acting in your session stays paused until you press Continue or allow the agent's ask; no idle timer | a take-over during a `wait`: the wait ends as interrupted too; a pause from before a restart: kept | input hook fake | +| **helper dies, engine lives** | engine stops acting within 500 ms of the missed heartbeat and releases held input | acting resumes only when the helper is back and the indicator is up | an engine from before a helper restart: the new helper stops it and starts a fresh one | kill test per OS | +| **engine dies mid-drag** | the engine writes every held key/button to a held-input file before sending it; the helper (or the next start) sends the ups, then deletes the file | after restart the batch is `unknown` from the journal and is not re-sent | a held-input file from before a reboot: dropped (the OS reset input) | kill test per OS | +| **clipboard write** | the previous content is saved in the journal before the write (encrypted for you when it carries a concealed marker) | after a crash the next start puts the saved content back if the clipboard still holds the agent's value, then deletes the saved copy | the clipboard changed by you since: left alone, saved copy deleted | fake clipboard | +| **speculative batch** | the journal lists each private layer **before** it is made; on restart every listed layer is deleted and its per-slot processes ended; a winner whose apply did not finish is re-applied file by file by the agent account (compare-and-swap makes done files a no-op); if that meets a file you changed meanwhile, the written files are rolled back from their pre-images (lens p7) | same key: the journalled winner and outcome, nothing re-runs; a key whose alternatives never finished: `unknown`, layers cleaned; `apply` on a layer that is gone: `layer_gone` | layers with no journal entry: left alone and reported; a real file changed during the run: `conflict`, nothing applied; a layer with a secret or auto-run path: refused before any write; zero alternatives: refused | fake engine + fake FS | +| **window pops in front** (§6.8) | the engine hides it and gives the front back; if the engine dies first, the window stays and the next start logs it | the same program is started the other way the result told the agent; a repeat offender is stopped | a recipe that now shows a window: `visible_side_effect`, recipe marked `needs repair` | no-pop-in test | +| recipe save | temp + atomic rename into the store | same content: no-op (content hash); new content: new version | a recipe for an older app version: runs, and a failing step marks it `needs repair`; empty store: fine | fake guard | +| pause / kill switch / revoke all | each is fsynced in the store and as a narrowing record in the guard's log **before** the tray confirms it; accounts disabled; links revoked one by one, each committed | idempotent; a revoke-all re-run revokes what is left | kill switch with no agent processes: OK; revoke-all with no links: OK; a narrowing the tray confirmed survives any power cut | fake OS layer + power cut | +| **wind down** | the `winding_down` state (start time, items, choice per item) is written first; after a helper crash it is read back and continues; parked and moved items are in the ledger and not moved again | pressing it again shows progress, starts nothing new | a `winding_down` state older than the last boot: cleared at the guard's next start in the protected store and in the T3 server's and runners' state together (unless "stay paused"); nothing running: "safe to shut down" at once | fake clock + fakes | +| **runner shrink** | the ledger line (job, run, attempt, choice) is written before the park or cancel; on restart, lines without "re-run asked" get it; a job already re-run (newer attempt exists) is marked done | same budget again: no-op; growth held off by the 2-minute rule; a second drop does not move the same attempt twice | `activity.json` stale or unreadable while someone is signed in: admit nothing new, tray note; unknown format: same; no jobs: only the heartbeat changes | fake dispatcher + fake meters | +| **power loss (any moment)** | every write above is atomic or journalled; narrowing is durable before it is confirmed; on start: store signature with narrowing replay, log tails, ledgered openings cleared and stored exceptions re-applied, AppContainer profiles and tombstones cleaned or rebuilt, deny pass and install verify by the guard, then the broker and the worker, held-input drop, journal `unknown`s, layer cleanup, unfinished snapshots discarded, runner ledger and lost-runner jobs re-asked | a second power loss during recovery: each recovery step is idempotent and runs again | lost: unsaved work inside apps; command changes after the last snapshot (T3 file tool changes have pre-images); never lost: a narrowing the tray confirmed | power-cut test in a VM per OS | +| **link extend** | the computer commits the new expiry in one transaction with the revocation check; the caller may miss the reply | extending again sets the same or a later expiry (idempotent); a revoke in flight always wins | a link expired more than 30 days ago: refused, pair again; a link with no recorded device: refused, pair again (tray note); the linking side still holds the old expiry: it updates from the next reply, or `usableLink` asks once before dropping the link; a lowered lifetime: existing links cut to it; `SessionStore.verify` reads the row's expiry, so an extended link works after its token's own `exp` | fake clock + two fake servers; extend then call after the old `exp`; lower the lifetime then call | +| **link used from an unknown device** | the call is refused before any tool runs | each further try is refused and counted | a link whose device identity was never recorded (older link): ordinary upstream calls work until its expiry; host tools and extension refused | fake tailnet whois | +| **replay cache** (proof ids) | each id is written as a replay marker file (fsynced) before the call runs; a crash after the write and before the call: the retry needs a new proof | the same proof again: refused | after a restart: the cache is still there; a lost or damaged cache: proofs issued before the start are refused for the accepted proof age | restart then replay; swapped body with a fresh proof refused | +| **linking side loses its device key** | its calls carry no valid proof and are refused before any tool runs | each try refused; the computer's tray shows the link as unusable, not as an attack | a new pairing of that machine revokes the old link (upstream #32639 idea); nothing extends the old one | delete the key, call, pair again | +| **T3 confirm pending item** (§4.2) | the item and its nonce live in the guard's memory only; a guard crash drops it, the panel closes, the engine stop is released, nothing is written | asking again makes a new item with a new nonce; a used or expired nonce is refused; one item per kind, a new item of the same kind from T3 or the tray replaces the old and re-arms with "this request changed", the agent's asks are rate-limited per task and per computer | a confirm from before a restart: gone, ask again; an item that waited in the queue: its expiry starts when shown | forged item refused; injected click, UIA Invoke, `BM_CLICK`, `AXPress` and AT-SPI `DoAction` refused; a replayed answer refused; overlapping window refused; `ClipCursor`, `SetCursorPos` and `CGWarpMouseCursorPosition` refused; the panel absent from a screen capture; guard crash while open | +| availability, project grants | settings saved | | "off until" in the past: on; bad value: off; a project with no setting: off | settings test | +| **host tools off / uninstall** | each step is listed in a removal ledger first; a half removal finishes on the next run | idempotent | removes: reach map grants, deny entries, auto-run denies and rename blocks, deny-execute entries on agent-placed files, AppContainer read ACEs and profiles, the guard's copies, firewall and network-floor rules (boot-time WFP filters, the `pf` anchor, the `nft` unit), the resolver and proxy exceptions, the DCOM, Task Scheduler and WMI denies (only the entries the guard added are removed; later changes by Windows or others stay), the WebClient start type and the SMB-over-QUIC setting (put back only if still the guard's value), the computer-wide SMB filters, the WSL / `hvc` file denies, the protected-area watch, the confirm panel, the `t3-net` service identity, services, the guard, snapshots it made, links it issued, the stored agent password, both accounts with their sign-in right settings, and the agent group (after you confirm their files); asks whether to put back the UAC level setup changed | uninstall test per OS | + +## 16. Tests (when built) + +- **Core on Linux CI with a fake OS layer:** policy checks (reach map, sensitive set, high-risk list, protected areas), + secret patterns (a hand-written fixture of expected secret paths per pattern, so weakening a pattern turns a test + red), the ask (scopes, remembered answers, wear-down rules, the T3 confirm items and the hardened confirm items), + step-list checks and limits, journal + and idempotency, input ladder logic with fake rungs (step-down, no double apply, rung memory), recipes, snapshot and + undo with writers (agent undo never touches a file you changed), log chain and countersign, pause, wind-down choices + by cost, availability and project grants (wind-down state cleared at the guard's start, in T3 and in runners), link extend and + device binding (`key_expired` for a key with a tombstone and no entry; extend then call after the old `exp`; lower the + lifetime then call; a swapped body with a valid proof refused), redaction of every output path, the take-over + pause (kept across a restart; only Continue or an allowed ask ends it; no timer ends it), narrowing replay after a + store fallback. +- **The toolkit** through upstream's MCP test kit: a forwarded call over a test link; a read-only link refusing write + tools; an outside-input thread getting read tools only; the caller id arriving on the computer; a token from another + device refused; **N2:** a forwarded thread launch, `delegate_task` and every other forwarded tool outside the + allowlist are refused on the computer, also when the agent machine runs an older build without the allowlist. +- **Per-OS layers** (a real run on each OS before that OS is enabled, Windows first): + - accounts: the agent account cannot read your profile root, a denied `.env`, T3's data or install, and cannot write + an auto-run path; the host account cannot be reached by agent commands; the broker refuses the agent account; + - links (B2): a planted junction, symlink and hard link do not widen a grant or redirect a restore or copy; + - B1: an admin command runs the frozen copy shown in the ask, not a file swapped after the ask; + - **protected areas (N1):** in a UI-acting batch the agent writes a `.bat` that adds a Run key, a Startup shortcut and + a scheduled task, then opens it through a file dialog; each change is blocked or held and reverted, the tray offers + Keep or Remove, and the activity log shows the write and the run; the same on macOS (a LaunchAgent) and Linux (a + systemd user unit, an XDG autostart entry); a write to a system area needs the elevation prompt and is refused by + the engine; a change made after the acting window by a process with a broken parent chain is shown by the + baseline diff; + - **N3:** renaming or deleting `.vscode`, `.idea` or `.git/hooks` as the agent account is denied; a new + `tasks.json`, `.envrc` or git hook from the agent account is quarantined; "make it mine" applies your denies; + - **N6:** a speculative winner that writes an auto-run path or a secret path is refused before any file is applied; + on macOS a slot user cannot read a denied `.env`; + - B3: an edited store file is ignored; the store's folder is refused in file dialogs; widening during a full-rights + grant needs the T3 confirm, and each rare action of §4.2 the hardened confirm (the OS admin prompt); + - network (M3, N5): no direct internet from the agent group; each class of §3.5 unreachable, including the + computer's own global IPv6 and LAN addresses and an on-link neighbour; a new address added mid-run is in the floor + before the next connection; only the named services on linked machines are reachable (none by default); a + per-goal ask opens exactly what it names through the guard's CONNECT/SNI proxy, also for a CDN host whose answers + change between lookups; **DNS:** a lookup of a name + outside the linked machines and the goal's hosts is refused by the guard's resolver, and a direct query to any + other resolver (including MagicDNS) is dropped; an opening left by a crash is gone after the next start; the + service-path probes (UNC, WebDAV, IPP printer, PAC, `WslLaunch`, BITS) each fail closed, also when a probe's + result cannot be read; **sec S4:** with a goal open, an opened name that resolves to a linked machine is refused by + the proxy, and with the public internet opened, a port on a linked machine that config does not name stays closed; + **sec S5:** a UNC path to a public test address over 445, an alternate port and SMB over QUIC, and an internet IPP + URL, are dropped while a LAN share and printer still work; the guard's resolver still resolves when the system's + DNS server is the router, a loopback stub or the tailnet resolver (correctness r3 major 2); on Linux `pasta` + started with `--runas` gives the namespace a route to the proxy and the resolver and nothing else; + - speculative (M2): a speculative run cannot reach the engine, the broker, T3 or a denied file in its layer, cannot + reach the network, and leaves no process after cleanup; + - clipboard (M4): concealed content refused; content put back after a batch; + - input (M5): a window that steals focus mid-burst gets no typed text; each rung reaches the test apps it claims and + steps down from the ones it cannot; + - **take-over:** a real click or key press during a rung-4 step stops it and releases all held input; on-screen + acting stays paused for every task, with no timer, while commands, file tools and the agents' browser go on; + Continue in the tray or T3, or your Allow on the agent's ask, ends the pause only through the T3 confirm (an MCP + session's Continue is refused); the pause survives a restart; + - **T3 confirm (§4.2):** an item forged by the agent (other hash, other wording) is refused; a click or key injected + by the agent account or by a process of yours is refused (`LLMHF_INJECTED`, macOS source state, XTest); a window + laid over the panel or a focus steal while it is open is refused and alerts; a confirm is accepted for the nonce + and hash shown only; a guard crash while the panel is open drops the item and + nothing is confirmed; the engine is stopped while the panel is open and starts again after; + - **T3 confirm, r7 (sec T5, T6, correctness K, R):** each injection route answers nothing: `SendInput` (also from + a `uiAccess` process), UI Automation Invoke, `BM_CLICK`, `WM_COMMAND` and keyboard messages, a synthetic `CGEvent`, + `AXPress`, XTest, AT-SPI `DoAction`, `uinput`; a changed system cursor image does not move the panel's hit test; + two "Continue" items show different codes; an answer replayed for the next item, after its use or after its + expiry is refused; on X11 no on-screen acting starts and high-risk items are refused; the agent's fourth ask in + 10 minutes, or the seventh across tasks, shows "too many requests" and leaves your pending item alone; the + hardened confirm is only ever the OS admin prompt, and nothing running as you changes that; item text with bidi and control characters and a + look-alike host name is shown escaped and in punycode; + - **steered input (sec S1):** with `ClipCursor` on the Allow button, a `SetCursorPos` or `CGWarpMouseCursorPosition` + warp just before a click, the click is refused; a screen capture of your session does not show the panel; on Linux + the code typed while the panel is open reaches no other app (keyboard grabbed), and a key between the code and + Enter starts it over; a replaced item shows "this request changed" and re-arms; + - **side starts (sec T1):** as the agent account, registering a scheduled task, `Win32_Process.Create` and + activating an out-of-process COM server outside the measured allow list, and Secondary Logon (`runas /netonly`), + all fail; the build tools the agent account needs (CIM queries, MSBuild) still run (correctness r3 major 1); no + agent-group process is ever found outside the guard's job; after a reset of one descriptor the daily verify turns + host tools off; installing, re-installing, a hand change to one AppID, then uninstalling leaves that hand change in + place and the ledger's first record unchanged (correctness r3 major 4); + - **unknown writer (sec T3, S6):** with ETW events dropped on purpose, a Run-key change while an agent process runs + is held and never copied into the guard's copy; with the reserved area full and large held changes in place, a + small Run-value change and a new Startup entry by an unknown writer are both neutralised in place and neither runs + at the next sign-in; a reboot with Windows updates after a clean guard stop holds nothing (correctness r3 major + 3); a retaken copy is marked unverified and never reverted to without your OK; + - **proxy floor (sec P1, correctness P):** an opened name that resolves to `127.0.0.1`, `169.254.169.254`, a LAN + address, a tailnet address and one of this computer's own addresses is refused, also when the answer changes + between two connections; on Linux the proxy and resolver are reachable from the namespace only through the two + `pasta` forwards; + - **host task (correctness H2):** after a reset of the host account's password, a reboot with nobody signed in still + starts T3 (S4U); + - **protected areas (r5):** the agent group cannot write any user-writable spot the reach check found + (`ProgramData`, a drive root, a writable Program Files folder or HKLM key, `/usr/local`); a scheduled task, WMI + subscription or user unit created during an acting window is held **and** the process it started is ended; a + process started by Task Scheduler, WMI, COM, `systemd --user` or `launchd` during a window is marked; a + persistence file deleted and recreated through its folder during an acting window is held; after a guard restart the marks (with start time) and a grant's + baseline are back, and processes their marked parents started while the guard was down are marked; running an + agent-placed file is refused (deny execute: a DLL loaded into a test service and a script run through an + interpreter are refused too), and elevated or persistence-registering runs need your OK; a held change to an + existing Run value and an existing shell profile is neutralised in place and the object restored from the guard's + copy, never deleted; a full reserved area refuses new agent work and leaves no held change active; + - **correctness (r5):** your sudo timestamps are cleared at the start of an acting batch or grant; a process started + by an app the engine acted in is marked within the 10 s window and not after, and its descendants too; unlocking + `.git/config` for one repo through the ask works and leaves other repos locked; the reach-check walk finds a + planted writable spot as the agent account and as your filtered token; the non-blocking install warnings appear + (UAC below "Always notify", docker or libvirt groups, polkit `auth_admin_keep`) without blocking; a revoke wins + over an extension in flight; the linking side picks up an extension from the next reply and `usableLink` asks + before dropping a link; the LAN route between two linked machines raises no unknown-device alert; a deleted + device key makes calls fail without a false attack alarm; a cloned repo whose `.vscode/tasks.json` runs on folder + open is quarantined and listed by "make it mine" (P6 c); a reused `host_do` key with other steps is refused; `apply` after a restart returns + `layer_gone`; a half-applied winner is rolled back from its pre-images; two speculative AppContainers cannot write + each other's layers; + - **undo store:** the 80 % warning, pruning of accepted goals only, growth within the budget, held items never + pruned, and the last-resort refusal with its message; + - the kill switch ends every agent process; pause ends daemonised children; the engine stops without the helper; + - **reboot with nobody signed in:** the guard runs the deny pass and the verify, starts the broker and the worker, and + host tools come on; a linked call and the agents' browser then work; the stored agent password still logs on; + - engine worker (M4): an agent command finds no endpoint of the worker's channel to open or squat; + - a snapshot restores a deleted file; on Linux without btrfs, ZFS or LVM-thin, `host_undo` of a command change says + "no undo for command changes here" instead of guessing; a snapshot macOS or VSS deleted on its own gives a tray + warning and journal-only undo, not a wrong restore; + - refusals (lock screen, password field, own policy screens, inactive session); + - power cut in a VM at random points during a batch, a speculative call, a wind-down and a runner shrink: the next + start recovers as §15 says; **N8:** a power cut just after the tray confirms a pause, a revoke or a narrowing, also + with the store file damaged: the narrowing holds after the restart; + - the §8.2 micro-benchmarks; the **no pop-in test** of §6.8 (required, blocks enabling an OS). +- **Runners:** the budget logic with fake meters (a game taking memory, a spike), finish / park / move choices by cost + with a fake dispatcher; the PC's dispatcher refuses a job whose event or actor is not in your policy even when the + server routed it there (lens p11); **N10:** a runner job cannot read the agent accounts' or your files, cannot reach the engine, + broker or T3, and leaves no process after it ends. + +## 17. Costs and open points + +- **The beta switch.** Linked environments is an upstream beta, off by default, and it must be on in **both** T3 + installs (agent machine and computer). +- **MCP tool-list size.** The host tools add several KB, so they are hidden unless at least one linked computer + reports host tools on, and then deferred behind `tool_search`. +- **Two user accounts, one service identity, an agent group and small services** on the computer (T3 as the host + account, the agent account in the `t3-agents` group, `t3-net` for the proxy and resolver, the broker, the guard), plus the desktop app's helper and, for speculative runs and + runners, extra accounts in that group. Setup is one installer run with one elevation prompt (one UAC prompt on + Windows; the admin password on macOS and Linux). +- **macOS prerequisites (lens p5).** Endpoint Security needs Apple's ES client entitlement (applied for per developer + account), a system extension the owner approves, and Full Disk Access. Without them: no blocking of protected-area + writes or runs on macOS; the guard falls back to FSEvents watching and revert, and the install verify says so. + `sandbox-exec` (used for speculative runs, §7.4) is deprecated by Apple though still shipped; a replacement is to + find before it goes. +- **Two T3 installs on the computer** if you also use the desktop app there; the host account's T3 is separate. +- **Delegated full agents on the computer** wait for the broker spawn hook (§3.2) and stay outside the forwarded-tool + allowlist until then. +- **A new native component** (the engine, and the small broker and guard) with a Rust build per OS in fork CI. +- **No screen of the agent's own on Windows and macOS in v1** (owner decision 2026-11-14). Windows rung 3 is a hidden + desktop in your session (acting as you, no real hardware-style input there); macOS has no rung 3. Apps that need + more use rung 4 with your OK. The later options and what each must prove first are in §6.9. +- **Wayland** rung 4 needs the RemoteDesktop portal, which asks once per session on some desktops; rung 2 does not + exist for native Wayland apps. + +**Decided by the owner (2026-11-13)**, kept here so the reasons stay with the design: acting in the UI acts as you, +with protected areas and the activity log (§4.4); take-over pauses until you continue, no idle timer (§6.7); links +expire after 365 days with one-click extension (§10); the default reach map is documents, desktop, downloads, media, +code and data drives, a list in config (§4.3); the OS consent prompt stays for full-rights grants (§4.2; replaced on 2026-11-14 by the hardened confirm, below); host tools are +off in new projects (§4.3); `.git/config` is read-only until you unlock a repo (§4.3); the agent group has no direct +internet, only your linked machines, with a per-goal ask for more (§3.5). + +**Owner answers to the r4 questions (2026-11-13):** + +1. **"Run" in protected areas** = running anything the agent itself placed there, anything elevated, and anything that + registers autostart or persistence; each needs your OK per action (§4.4). +2. **Take-over pause** stops only on-screen acting; background commands go on (§6.7). +3. **After a take-over** the agent's renewed ask needs your Allow; no automatic continue (§6.7). +4. **UAC:** setup sets "Always notify" by default; you can turn it off (§3.3, §4.4). +5. **Windows driver:** not now; only if measurements show it is needed (§4.4). +6. **Undo store:** sized so it does not fill in normal use: from measured need, on a drive with room, growing within a + disk budget you set, pruning only copies of accepted or finished goals (never held or quarantined items, never inside + the undo window), warning at 80 %; refusing writes stays only as the last-resort guard, on by default (§4.3 rule 6). +7. **User-writable system places** are protected areas by default: deny entries for the agent group, watched for you, + with the residual said openly (§4.4). + +**Owner answers 2026-11-14 (decided):** + +1. **No Remote Desktop session for the agent.** The agent account never signs in interactively; its password is set + once and kept by the guard (no daily rotation, no task password to keep in step). The agent still does its work: + through commands, file tools, its own hidden browser, your apps with your OK (the take-over rules unchanged) and + your linked machines (§3, §6.4). +2. **No own Windows screen for the agent in v1**, and no spike for one now. Windows Sandbox, a hidden desktop of the + agent account and a spare PC or VM linked like any machine stay as later options, each with what it must prove + first (§6.9). +3. **No OS prompt for Continue and for Allow on a renewed ask.** Both go through the T3 confirm only, which the guard + makes unforgeable by the agent: real input only, the engine and agent input blocked while it is open, bound to the + one item by its one-time nonce (§4.2, §6.7; the confirm from a phone or another computer is deferred, issue #28). The residual risks + (remote-control and on-screen-keyboard input count as injected) are in §4.2. +4. **Linked machines: only the services named in config, default none** (§3.5 has a worked example). + +**Owner answers to the r6 questions (2026-11-14), decided:** + +1. **Rare high-risk actions use the hardened confirm** (§4.2; in v1 the OS admin prompt, issue #28): full-rights grants, Allow on elevation and + protected-area items, Keep of a held change, "make it mine", host tools back on and confirming an outside store + edit. The OS's own prompt stays only where the OS demands it: running as admin (UAC on Windows, `sudo`/polkit on + Linux, the authorization dialog on macOS) when the elevated command runs. +2. **WebClient (WebDAV) off for the whole computer** at install, back on at uninstall (the old start type is in the + ledger, recorded once; put back only if still the guard's value), shown in the owner's view (§3.5). For SMB and the print spooler a per-account block is looked for in the + build; where none holds, the residual is named (§3.5, §14) and host tools stay off unless you accept it by name. +3. **Remote-control and on-screen-keyboard input is refused by the T3 confirm.** Confirming from your phone or + another of your linked computers instead is deferred (issue #28, list below); until then you confirm at the computer. + +**Pending owner (asked 2026-11-14), written with the recommended default (yes):** + +1. Hardened confirm = the OS's own admin prompt in v1, a phone or second computer later: + https://github.com/scuttletoby/t3code/issues/28 +2. Block SMB and printers to the internet for the whole computer (§3.5): + https://github.com/scuttletoby/t3code/issues/29 + +**Deferred: remote confirm (phone / second computer), own design doc later** (cut in revision 9 by the stuck audit): + +- A phone passkey confirm page, run as its own `t3-confirm` service identity, with its TLS key, a tailnet-only port and + an exact origin check. +- Registering a phone with a single-use code and a matching fingerprint. +- A second computer as a confirm computer: guard confirm keys, guard identity keys exchanged at pairing, enrolment. +- The remote confirm for remote-control and on-screen-keyboard input. +- The hardened confirm taken off this computer's screen. +- On-screen acting and high-risk OKs on X11, and high-risk OKs on Linux desktops whose polkit agent can be replaced. + +**Minors to settle in the build** (audit list, not one-line fixes): + +- Network openings (a per-goal host, a linked service) reach every agent task, not only the one that asked (sec m4). +- Audit rule 1 (§3.4) in practice: the build names the exact calls for a give as you (the guard's copy handed to a + process running as you, the S4U or `setfsuid` token) and for the guard undoing its own take-away, or refuses the + give. + +**Open for the owner:** the two issues above, nothing else. + +**Follow-up research (named here, not done in this design):** which command languages and tools agents should use on +each OS for speed and reliability, general across projects: whether agents should write Python at all; how good the +Python, PowerShell and shell they write is in practice; built-in commands versus scripts; and a curated, fast toolset +per OS (startup time, output size, error behaviour) that `host_status` could advertise to agents. The result feeds the +tool descriptions and the recipe guidance. + +## General vs project + +Everything in this design is general. Machine names, addresses, accounts, folders, apps, games, bridges, recipes and +named commands are local policy and settings on each install; examples use placeholders only. From 36b40ae9f82ae8f0ffeb987bcb41f69cc414119b Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:17:05 +0100 Subject: [PATCH 22/27] fork-ci pr-queue: leftover minors from the stack reviews (#32) - lens.py: linear-time FIELD_RE/READ_AT_RE/lens_key (a trusted 64 KB comment or many capped stranger posts cost no time); the way out of a malformed lens comment named in one place (NO_HEADER_WAY_OUT) and in the queue's advice. - pr_queue.py: red_checks takes the newest run per name across workflows and counts a cancel as red only after a red with nothing green since; stuck() waits while a workflow runs on the head also for an ended check, and calls a head with no check at all stuck once its workflow runs ended; tell_red posts nothing when the head moved since the list; the base-red notice names and is keyed by the check that decided; an UNKNOWN merge state is listed again for up to wait_seconds; info/attributes makes the cache rebuild. - Tests: PR reviews, cache mode 0755, plan file fchmod/O_NOFOLLOW, a counted association for the never-a-lens checks, and one test per behaviour fix. - README: stacked-PR paragraph out, four port differences, the ways out of a malformed comment, why the plan is on disk. Co-authored-by: Claude Opus 5.5 --- fork-ci/actions/pr-queue/README.md | 79 ++++++---- fork-ci/actions/pr-queue/lens.py | 47 ++++-- fork-ci/actions/pr-queue/lens.test.py | 84 +++++++++++ fork-ci/actions/pr-queue/pr_queue.py | 136 +++++++++++------ fork-ci/actions/pr-queue/pr_queue.test.py | 172 ++++++++++++++++++++-- 5 files changed, 418 insertions(+), 100 deletions(-) diff --git a/fork-ci/actions/pr-queue/README.md b/fork-ci/actions/pr-queue/README.md index 64aeaf05dd97c..35b13f9f2eac6 100644 --- a/fork-ci/actions/pr-queue/README.md +++ b/fork-ci/actions/pr-queue/README.md @@ -5,16 +5,6 @@ up to date with their base branch. After each push to the base branch it brings exactly one waiting pull request up to date, and it turns auto-merge back on only for a head that holds exactly the change the review read. -This folder is built in four stacked pull requests: - -1. the review rules and the config (`lens.py`, `config.py`); -2. the queue itself (`pr_queue.py`, section "The queue"); -3. armed pull requests that cannot merge (a red required check, or one that - ended without passing or never reported) are told once and passed over; -4. the "red only because the base was red" rule, the reusable workflow - `.github/workflows/fork-pr-queue.yml`, `action.yml`, `tested-base/` and the - usage sections of this file: this part. - ## Config One JSON file in the project's repository. Unknown keys, a key written twice, a @@ -93,6 +83,12 @@ heading near the top, or two `covered:`/`checks:` lines. A comment that is no report says so on its first line, `not a lens: ...`. That clears it unless one of its lines is a lens header or a `lens:` field with a sha. +A comment that looks like a report but is not one valid read is a lens of its +own, keyed by that comment. Posting the report again the right way adds a new +read and leaves the old comment blocking, and hiding it is not enough either. +The ways out: delete it, edit its first line to `not a lens: ...` with no lens +header line left in it, or post an override for the head. + ## The queue ``` @@ -109,26 +105,38 @@ first, and acts on the first one it can: the way out. A pull request in a merge state the queue neither updates nor waits for (a -conflict, `DIRTY`) is passed over, and the run says so. +conflict, `DIRTY`) is passed over, and the run says so. `UNKNOWN` (GitHub is +still computing it, often for a few seconds after a push to the base) is listed +again every `poll_seconds`, for at most `wait_seconds`, before it is passed +over, so a younger pull request is not updated ahead of it. An armed pull request that cannot merge does not hold the queue. It is told why on the pull request, once per head, check and reason, and passed over: - a required check whose newest run failed (`FAILURE`, `ERROR`, `TIMED_OUT`, - or `CANCELLED` after a red with nothing green since), behind or not. Its - author fixes it; nothing in the queue re-runs it. + or `CANCELLED` after a red with nothing green since), behind or not. The + newest run of the name counts, whichever workflow ran it, as the ruleset + reads it. Its author fixes it; nothing in the queue re-runs it. - up to date, and a required check's newest run ended without passing and - without a red (`CANCELLED`, `STARTUP_FAILURE`, `STALE`, ...): nothing + without a red (`CANCELLED`, `STARTUP_FAILURE`, `STALE`, ...), and no + workflow runs on the head any more (a newer run may still be queued): nothing re-runs it by itself. The comment says to re-run the CI run or push. - up to date, and a required check never reported although no workflow runs - on the head any more. The comment says to push or merge the base: a re-run - repeats the jobs of the workflow files the head was built with, so it cannot - add a check those files do not have (one added or renamed since). + on the head any more, also when no check at all reported. The comment says + to push or merge the base: a re-run repeats the jobs of the workflow files + the head was built with, so it cannot add a check those files do not have + (one added or renamed since). + +A head with no check and no workflow run at all still holds the queue: nothing +tells "not started yet" from "never starts" (every workflow filtered out by +its paths) without a clock, and the queue keeps no state for one. Only the newest run of a check counts, and a check not in `required_checks` is ignored. Only the queue's own comment (posted with its token) counts as told: -a copy of it by anyone else does not silence it. When GitHub cannot say which workflows still run on the head, the -pull request is not called stuck: it holds the queue, and the run says why. +a copy of it by anyone else does not silence it. A pull request whose head +moved between the list and the comment is told by the next run, on its new +head. When GitHub cannot say which workflows still run on the head, the pull +request is not called stuck: it holds the queue, and the run says why. `gh` must be logged in for the repository (`GH_TOKEN`). Git fetches into the queue's own bare repository (`--cache`) and gets its login from @@ -140,8 +148,9 @@ other repository's job, as a user of its own. The `--state-file` and `--cache` folders belong to that user alone. git runs with no hooks, no fsmonitor and no replace refs, and a cache that is not that user's alone (mode `0700`), has a config the queue did not write, or holds a file that changes what its objects -say (`info/grafts`, `shallow`, `objects/info/alternates` or -`objects/info/http-alternates`) is rebuilt, never used. A job of the same user +say or how a merge reads them (`info/grafts`, `shallow`, +`objects/info/alternates`, `objects/info/http-alternates` or +`info/attributes`) is rebuilt, never used. A job of the same user can still change the cache between that check and git's next read: only the runner rule above stops that. @@ -169,7 +178,15 @@ was: give each queue its own `--state-file`. A plan without `repo` (the source queue's format) is this repository's. An unreadable plan, or one another user could have written (not this user's, or writable by its group or others), is kept as `STATE.bad`, the run fails, and every later run warns while that file -exists. +exists. A plan does not say who turned auto-merge off: a pull request a person +disarmed after the stop is re-armed too, if its head holds the change read. + +The plan is on disk, and that is why the runner rule above exists. It could +live in a comment of the queue's own login instead (trusted by +`viewerDidAuthor`, as the told notices are): the next run reads the review and +the base from GitHub anyway, and a failed write before the disarm would only +mean no disarm. That stays open for a queue that moves several pull requests at +once, and for GitHub-hosted runners. `--lock-file` (default: `STATE.lock`) keeps two runs from overlapping. `--dry-run` takes no lock and writes nothing: no `STATE`, no cache, no comment. @@ -191,7 +208,10 @@ red required checks is therefore updated when, for every red required check: When the base tip is not green yet, the pull request is told once, per head and check, that it failed as on its base and that the queue updates it by -itself once the base is green. It does not hold the queue meanwhile. When +itself once the base is green. The notice names the first check still red on +the base; when the base fixes that one and another is still red there, a new +notice names that one. A red of the pull request's own beside a base red is +named as its own ("fix the cause"). It does not hold the queue meanwhile. When anything is unknown (no note among the job's first 100 annotations, a failed read, more than 100 checks on a commit), the red counts as the pull request's own, as without the rule. There is no state and no loop: after the update the new runs test a @@ -300,12 +320,14 @@ replaced, and the replaced corpus was checked against the source copy before it was committed. A rule change here must keep the corpus green or say in its pull request why a recorded answer changes. -The port differs on purpose in three ways, and the replay checks each recorded +The port differs on purpose in four ways, and the replay checks each recorded answer that changes for its reason. The source's repository is private, so its comments carry no author association: the replay gives them `OWNER` and unedited. A read shorter than 40 hex, and a lens comment edited after posting, read nothing here (54 recorded answers). The source's `full_sha`, which matched -a short read to the pull request's commits, is gone. +a short read to the pull request's commits, is gone. A hidden (minimized) lens +comment reads nothing: the source never read `isMinimized`, and no recorded +call has it, so no recorded answer changes for it. `pr_queue.py` is a port of the same project's queue, and `pr_queue.test.py` holds that project's queue tests with the port's names. What the port changed: @@ -317,7 +339,12 @@ queue's own advice in a comment keeps its backticks and `` placeholder required check that never reported, the comment says to push or merge the base; the source said to re-run, which cannot add a check the head's workflow files do not have. With two red checks the comment names the first by name -(the source named whichever the check list gave first). +(the source named whichever the check list gave first). A red check is the +newest run of its name whichever workflow ran it, and a red that a green re-run +and then a cancelled run followed is not red (the source took the newest run +per workflow, and counted any red before a cancel). An `UNKNOWN` merge state is +listed again (the source passed it over at once), and a cancelled check waits +while a workflow still runs on the head (the source called it stuck at once). The "base was red" rule is a port of a change proposed to the source queue, with its tests. What the port changed: the rule is off unless the config turns diff --git a/fork-ci/actions/pr-queue/lens.py b/fork-ci/actions/pr-queue/lens.py index 4e2448b718e13..f30c949d6388c 100644 --- a/fork-ci/actions/pr-queue/lens.py +++ b/fork-ci/actions/pr-queue/lens.py @@ -27,10 +27,17 @@ # The name ends at the first field: ` · reviewer:` or ` · read at:` (also `read at `; the separator may also be # `,` `—` or none). A bare `reviewer` or `read at` stays in the name (`data-integrity reviewer 1` and `... 2` are two # lenses). -FIELD_RE = re.compile(r'\s*(?:[·,—]\s*)?\b(?:reviewer\s*:|read[\s-]*at\b\s*(?::|[0-9a-f]{7}))', re.I) -READ_AT_RE = re.compile(r'\bread[\s-]*at\b\s*:?\s*([0-9a-f]{7,40})\b', re.I) +# Every pattern here runs in time linear in the line, also for a trusted author's 64 KB comment: a blank run is tried +# once, from its start ((?`, until nothing changes) + # copied the whole name per pass: minutes for a 64 KB name of 13 K markers. The same steps on a word list, where a + # word stripped empty at an end stays as the blank in front of a marker until the next pass, as in the text. + words = name.lower().split() + lo, hi = 0, len(words) + while True: + while lo < hi and not words[lo]: + lo += 1 + while lo < hi and not words[hi - 1]: + hi -= 1 + if lo == hi: + return 'unnamed' + before = (words[lo], words[hi - 1], hi) + words[lo] = words[lo].lstrip('*_`') + words[hi - 1] = words[hi - 1].rstrip('*_`') + if hi - lo >= 2 and words[hi - 1] and re.fullmatch(r'r\d+|lens|re-?check', words[hi - 1]): + hi -= 1 + if (words[lo], words[hi - 1], hi) == before: + return ' '.join(w for w in words[lo:hi] if w) or 'unnamed' def names_a_lens(name): @@ -266,11 +286,12 @@ def lens_posts(self, pr): def lens_entries(self, pr): """{lens key: ((when, index), sha or None, why or None)}: each lens's NEWEST header, from the posts that count (counts). A comment that looks like a lens report but is not one valid read (lens_problem) is an entry of its - own with no sha: it read nothing until it is re-posted as one comment per lens with a first-line header, or an - override covers it. A lens comment edited after posting (includesCreatedEdit not false: GitHub records that it - changed, not what) or hidden (minimized) read nothing until that lens posts a new comment. A lens-looking post - that does not count is logged (log only, never a comment), so a wrong lens_author_associations shows in the - run.""" + own, keyed by the comment, with no sha: a correct new post of that lens does not replace it, and hiding it + does not clear it. It reads nothing until it is deleted, its first line is edited to `not a lens: ...` with no + lens header line left in it, or a newer override covers it (NO_HEADER_WAY_OUT). A lens comment edited after + posting (includesCreatedEdit not false: GitHub records that it changed, not what) or hidden (minimized) reads + nothing until that lens posts a new comment. A lens-looking post that does not count is logged (log only, never + a comment), so a wrong lens_author_associations shows in the run.""" found = {} for when, i, post in self.lens_posts(pr): where = post.get('url') or f'by {(post.get("author") or {}).get("login") or "?"} at {when or "?"}' diff --git a/fork-ci/actions/pr-queue/lens.test.py b/fork-ci/actions/pr-queue/lens.test.py index 164dbb4656f93..6520689975a29 100644 --- a/fork-ci/actions/pr-queue/lens.test.py +++ b/fork-ci/actions/pr-queue/lens.test.py @@ -208,6 +208,41 @@ def test_a_strangers_huge_comment_costs_no_time(self): # SEC-2 r3 major: anyon self.head(self.OWN, who) self.assertLess(time.monotonic() - start, 5, (body[:20], who.get('authorAssociation'))) + def test_a_trusted_authors_long_blank_or_marker_runs_cost_no_time(self): # r4 sec: 30-60 s each before + import time + shapes = ('lens: a' + ' ' * 65536 + 'b', # FIELD_RE: one blank run per start + f'lens: a · read at: {SHA_A}\nread at' + ' ' * 65536 + 'x', # READ_AT_RE: \\s*:?\\s* split a run + f'lens: a · read at: {SHA_A}\nread at' + '\u00a0' * 65536 + 'x', + 'lens: a' + ' r1' * 21000, 'lens: a' + ' lens' * 13000) # lens_key: one pass per marker + for body in shapes: + start = time.monotonic() + self.head(post(body)) + self.assertLess(time.monotonic() - start, 5, body[:20]) + + def test_many_capped_stranger_posts_cost_no_time(self): # r4 sec: 0.76 s per 4096-char post before + import time + body = 'lens: a' + ' ' * (lens.STRANGER_SCAN - 9) + 'b' + posts = [stranger(body, url=f'https://github.test/pr/7#c{i}') for i in range(20)] + start = time.monotonic() + self.assertEqual(self.head(self.OWN, *posts), (True, SHA_A, 'every lens read it')) + self.assertLess(time.monotonic() - start, 5) + + def test_a_malformed_lens_comment_blocks_until_deleted_edited_or_overridden(self): + # r3: the advice "re-post it" could not work: the malformed comment is its own entry, keyed by the comment + fence = '`' * 3 + bad = post(f'{fence}\nlens: correctness · reviewer: x · read at: {SHA_A}\n{fence}', url='https://github.test/c1') + good = post(f'lens: correctness · reviewer: x · read at: {SHA_A}', when='2026-01-02T00:00:00Z') + self.assertEqual(self.head(bad, good)[:2], (True, None), 'a correct re-post leaves the old one in force') + self.assertEqual(self.head(dict(bad, isMinimized=True), good)[:2], (True, None), 'hiding is not enough') + self.assertEqual(self.head(good), (True, SHA_A, 'every lens read it'), 'deleted') + edited = dict(bad, body='not a lens: an old draft, see the next comment', includesCreatedEdit=True) + self.assertEqual(self.head(edited, good), (True, SHA_A, 'every lens read it'), 'its first line edited') + kept = dict(bad, body=f'not a lens: draft\nlens: correctness · reviewer: x · read at: {SHA_A}') + self.assertEqual(self.head(kept, good)[:2], (True, None), 'the opt-out does not clear a header line left in it') + over = post(f'tool ready --head {SHA_A}: read it', when='2026-01-03T00:00:00Z') + self.assertEqual(self.head(bad, good, over), (True, SHA_A, 'every lens read it'), 'a newer override') + self.assertIn('delete that comment (hiding it is not enough)', lens.NO_HEADER_WAY_OUT) + def test_a_lens_comment_whose_author_does_not_count_is_logged(self): # a wrong lens_author_associations makes reviewed PRs look unreviewed: the run log must say why lines = [] @@ -252,6 +287,55 @@ def test_an_override_counts_whether_or_not_its_association_writes_lenses(self): self.assertEqual(r.reviewed_head(pr(lens_post, over)), (True, SHA_B, 'every lens read it')) +class Reviews(unittest.TestCase): + """PR reviews take part like comments (lens_posts): ordered by submittedAt, by the same author rules. gh gives no + isMinimized for a review, so a review counts as shown.""" + R = WhoCounts.R + + def review(self, body, when='2026-01-02T00:00:00Z', **extra): + p = post(body, when=..., **extra) + p['submittedAt'] = when + return p + + def head(self, comments=(), reviews=()): + return self.R.reviewed_head({'headRefOid': SHA_B, 'comments': list(comments), 'reviews': list(reviews)}) + + def test_a_counted_review_is_a_read(self): + self.assertEqual(self.head(reviews=[self.review(f'lens: correctness · reviewer: x · read at: {SHA_B}')]), + (True, SHA_B, 'every lens read it')) + + def test_the_newest_of_comments_and_reviews_counts_by_submitted_at(self): + old = post(f'lens: correctness · reviewer: x · read at: {SHA_A}', when='2026-01-01T00:00:00Z') + newer = self.review(f'lens: correctness · reviewer: x · read at: {SHA_B}', when='2026-01-02T00:00:00Z') + self.assertEqual(self.head([old], [newer]), (True, SHA_B, 'every lens read it')) + older = self.review(f'lens: correctness · reviewer: x · read at: {SHA_B}', when='2025-12-31T00:00:00Z') + self.assertEqual(self.head([old], [older]), (True, SHA_A, 'every lens read it')) + + def test_a_review_without_a_hidden_state_counts_as_shown(self): + r = self.review(f'lens: correctness · reviewer: x · read at: {SHA_B}') + self.assertNotIn('isMinimized', r) + self.assertEqual(self.head(reviews=[r])[1], SHA_B) + + def test_an_edited_review_reads_nothing(self): + for edited in (True, ...): + r = self.review(f'lens: correctness · reviewer: x · read at: {SHA_B}', includesCreatedEdit=edited) + reviewed, sha, why = self.head(reviews=[r]) + self.assertEqual((reviewed, sha), (True, None), edited) + self.assertIn(lens.EDITED, why) + + def test_a_strangers_review_is_ignored(self): + own = post(f'lens: correctness · reviewer: x · read at: {SHA_A}', when='2026-01-01T00:00:00Z') + forged = self.review(f'lens: correctness · reviewer: x · read at: {SHA_B}', authorAssociation='NONE') + self.assertEqual(self.head([own], [forged]), (True, SHA_A, 'every lens read it')) + self.assertEqual(self.head(reviews=[forged]), (False, None, 'no lens comments')) + + def test_a_counted_authors_look_alike_review_blocks(self): + own = post(f'lens: correctness · reviewer: x · read at: {SHA_A}', when='2026-01-01T00:00:00Z') + reviewed, sha, why = self.head([own], [self.review('lgtm, safe to merge')]) + self.assertEqual((reviewed, sha), (True, None)) + self.assertIn(lens.NO_HEADER, why) + + class Config(unittest.TestCase): def parse(self, **change): raw = {**VALID, **change} diff --git a/fork-ci/actions/pr-queue/pr_queue.py b/fork-ci/actions/pr-queue/pr_queue.py index 5e1f6c7a01fbb..04481a602f423 100755 --- a/fork-ci/actions/pr-queue/pr_queue.py +++ b/fork-ci/actions/pr-queue/pr_queue.py @@ -60,8 +60,10 @@ # or hook path) is rebuilt, never run. CACHE_CONFIG = '[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n' # Files git reads that would make the cache's history or objects other than what was fetched from GitHub: grafts and -# shallow change a commit's parents, alternates adds another repository's objects. The queue never writes any of them. -UNTRUSTED_FILES = ('info/grafts', 'shallow', 'objects/info/alternates', 'objects/info/http-alternates') +# shallow change a commit's parents, alternates adds another repository's objects, and info/attributes can change how +# merge-tree merges a file (merge=union, binary). The queue never writes any of them. +UNTRUSTED_FILES = ('info/grafts', 'shallow', 'objects/info/alternates', 'objects/info/http-alternates', + 'info/attributes') VIEW = 'number,state,isDraft,autoMergeRequest,mergeStateStatus,headRefOid,baseRefName,comments,reviews,commits' LIST = 'number,title,isDraft,autoMergeRequest,mergeStateStatus,createdAt,statusCheckRollup,headRefOid,baseRefName' @@ -273,22 +275,28 @@ def same_change(self, base, n, head, read): # --- which armed PRs wait, and why ------------------------------------------------------------------------------ def red_checks(self, pr): - """{name: rollup item of its newest run} of each required check whose newest run really failed: the rollup - lists every run on the head, and an older red must not outvote a newer run (lens.latest_checks). CANCELLED - alone is not a failure (a newer push or update cancels the old run), but a red that only a cancelled run - followed still stands: nothing ever passed.""" + """{name: rollup item of its newest run} of each required check whose newest run really failed. The ruleset + takes the newest run of a name, whichever workflow ran it (required_now), and the rollup lists every run on the + head: an older red must not outvote a newer run. CANCELLED alone is not a failure (a newer push or update + cancels the old run), but a red that only cancelled runs followed still stands: nothing passed since it.""" rollup = pr.get('statusCheckRollup') or [] name_of = lambda c: c.get('name') or c.get('context') state_of = lambda c: (c.get('conclusion') or c.get('state') or '').upper() - out = {} + when = lambda c: c.get('startedAt') or '9999' + newest = {} for c in lens.latest_checks(rollup): name = name_of(c) - if name not in self.cfg.required_checks: - continue - if state_of(c) in RED_STATES or (state_of(c) == 'CANCELLED' and - any(name_of(o) == name and state_of(o) in RED_STATES for o in rollup)): - if (c.get('startedAt') or '') >= ((out.get(name) or {}).get('startedAt') or ''): + if name in self.cfg.required_checks and (name not in newest or when(c) >= when(newest[name])): + newest[name] = c + out = {} + for name, c in newest.items(): + if state_of(c) == 'CANCELLED': + runs = [o for o in rollup if name_of(o) == name] + green = max((when(o) for o in runs if lens.check_state(o) == 'ok'), default='') + if any(state_of(o) in RED_STATES and when(o) > green for o in runs): out[name] = c + elif state_of(c) in RED_STATES: + out[name] = c return out def failing(self, pr): @@ -322,25 +330,31 @@ def workflow_runs(self, head): def stuck(self, p): """An up-to-date armed PR that cannot merge and that nothing moves on any more: (check, what) for a required check whose newest run ended without passing and without a red (CANCELLED, STARTUP_FAILURE, STALE...), or that - never reported although no workflow runs on the head any more. None: it may still merge (green or running).""" - now = self.required_now(p.get('statusCheckRollup') or []) + never reported, in both cases only while no workflow runs on the head any more (a newer run queued with no job + yet may still report it). None: it may still merge (green or running), or GitHub could not say (printed). A + head with no check and no workflow run at all waits: nothing tells "not started yet" from "never starts" + (README.md, "The queue").""" + rollup = p.get('statusCheckRollup') or [] + now = self.required_now(rollup) ended = sorted((name, state) for name, state in now.items() if state not in ('ok', 'running')) - if ended: - return ended[0][0], f'ended {ended[0][1]}' missing = sorted(self.cfg.required_checks - set(now)) - if 'running' in now.values() or not missing: - return None - if not (p.get('statusCheckRollup') or []): - print(f'#{p["number"]}: no check has started on its head yet') + if 'running' in now.values() or not (ended or missing): return None head = (p.get('headRefOid') or '').lower() runs = self.workflow_runs(head) if runs is None: # unknown is not "nothing runs": wait, and say it - print(f'#{p["number"]}: required check {", ".join(missing)} has not reported, and which workflows still ' - f'run on {head[:12] or "its head"} is unknown: waiting') + what = (f'required check {ended[0][0]} ended {ended[0][1]}' if ended + else f'required check {", ".join(missing)} has not reported') + print(f'#{p["number"]}: {what}, and which workflows still run on {head[:12] or "its head"} is unknown: ' + 'waiting') return None if any((x.get('status') or '').lower() != 'completed' for x in runs): return None + if ended: + return ended[0][0], f'ended {ended[0][1]}' + if not rollup and not runs: + print(f'#{p["number"]}: no check and no workflow run on its head yet: waiting') + return None return missing[0], NEVER def stuck_fix(self, what, base): @@ -350,14 +364,18 @@ def stuck_fix(self, what, base): 'it, so that the current workflows run') return 'Nothing re-runs it by itself: re-run the CI run (Actions, "Re-run failed jobs") or push' - def tell_red(self, n, check, what='is red', fix='Fix the cause and push'): + def tell_red(self, n, head, check, what='is red', fix='Fix the cause and push'): """An armed PR the queue skips says why on the PR, once per head, check and reason. No answer from GitHub: the next run tries again. Told means a comment of the queue's own login (viewerDidAuthor) that starts with the mark: - anyone can post a copy of it on a public repository.""" + anyone can post a copy of it on a public repository. `head` is the head the reason was found on (the list): + after a push the reason is not known for the new head, and a mark for it would silence its real notice.""" pr = self.view(n) if not pr: print(f'#{n}: could not read it to say why it waits') return + if (pr.get('headRefOid') or '').lower() != (head or '').lower(): + print(f'#{n}: its head moved from {(head or "?")[:12]} since the list: the next run tells it') + return mark = f'required check {check} {what} on {(pr.get("headRefOid") or "?")[:12]}' told = f'pr_queue: {mark},' if any(c.get('viewerDidAuthor') is True and (c.get('body') or '').startswith(told) @@ -430,35 +448,37 @@ def commit_checks(self, expr, cache): return out def red_from_old_base(self, p, cache): - """A BEHIND PR whose required checks failed only because the base they were tested on was red. (None, None) - when, for EVERY red required check C, the base commit its run really merged with (tested_base) has a real + """A BEHIND PR whose required checks failed only because the base they were tested on was red. (None, None, + False) when, for EVERY red required check C, the base commit its run really merged with (tested_base) has a real failure of C (BASE_RED) and the base branch has C green now: an update makes a new merge on that green tip. - Otherwise (why not, base_check): base_check names a check that failed on the base too and is still not green - there, set only when EVERY red check failed on its tested base (one red of the PR's own, or one nothing proves, - means its author fixes it: False), so the PR waits for its base, not for its author. No state and - no loop: after the update the next run names a base where C is green, so a red then is the PR's own and it is - passed over (told); a re-run of the old merge names the old red base again and is updated, which it needs.""" + Otherwise (why not, check, base_red): `check` is the check that decided, for the notice (None: no one check + did). base_red is True only when EVERY red check failed on its tested base and `check` failed on the base too + and is still not green there, so the PR waits for its base, not for its author; one red of the PR's own, or one + nothing proves, means its author fixes it, and `check` names that one. No state and no loop: after the update + the next run names a base where C is green, so a red then is the PR's own and it is passed over (told); a re-run + of the old merge names the old red base again and is updated, which it needs.""" reds, base = self.red_checks(p), p.get('baseRefName') or '' if not re.fullmatch(r'[\w./-]+', base): - return 'its base branch is unknown', False + return 'its base branch is unknown', None, False tip = self.commit_checks(f'refs/heads/{base}', cache) if not tip: - return f'the checks of {base} are unknown (see above)', False + return f'the checks of {base} are unknown (see above)', None, False waits = None for check, c in sorted(reds.items()): tested = self.tested_base(c) if not tested: - return f'the base its {check} run tested is unknown (see above)', False + return f'the base its {check} run tested is unknown (see above)', check, False then = self.commit_checks(tested, cache) if not then: - return f'the checks of {base} commit {tested[:12]} are unknown (see above)', False + return f'the checks of {base} commit {tested[:12]} are unknown (see above)', check, False was = then[1].get(check, 'not reported') if was not in BASE_RED: # running, ok, cancelled, skipped, stale...: nothing proves the base was red - return f'{check} was not red on the {base} commit {tested[:12]} its run tested ({was})', False + return f'{check} was not red on the {base} commit {tested[:12]} its run tested ({was})', check, False now = tip[1].get(check, 'not reported') if now != 'ok' and not waits: # keep looking: a later red of the PR's own is its author's to fix - waits = f'{check} failed on {base} {tested[:12]} too and is not green on {base} now ({now})', check - return waits or (None, None) + waits = (f'{check} failed on {base} {tested[:12]} too and is not green on {base} now ({now})', check, + True) + return waits or (None, None, False) # --- steps 2-4 -------------------------------------------------------------------------------------------------- def view(self, n): @@ -495,8 +515,9 @@ def read_advice(self, why): """The way out when the review names no head, for the queue's comment.""" if lens.NO_HEADER in why: return ('A comment that looks like a lens report counts only with a lens header as its first plain line, ' - f'not in a code block ({self.cfg.lens_format_doc}): re-post it that way, or someone who read the ' - f'head posts the override `{self.cfg.override_prefix} : `.') + f'not in a code block ({self.cfg.lens_format_doc}). Posting it again that way does not clear ' + f'the old one: {lens.NO_HEADER_WAY_OUT} someone who read the head posts the override ' + f'`{self.cfg.override_prefix} : `.') if lens.SHORT in why: return f'A lens read names the full 40-hex sha ({self.cfg.lens_format_doc}): re-post it with that.' if lens.HIDDEN in why: @@ -697,6 +718,19 @@ def dry_run(self): return self.run(True) def run(self, dry): + """One sweep in age order: update or wait for the first PR that can move. An UNKNOWN merge state (GitHub is + still computing it, often for seconds after a push to the base) is listed again every poll_seconds, at most + wait_seconds, before the PR is passed over, so a younger PR is not updated ahead of it.""" + waits = self.cfg.wait_seconds // self.cfg.poll_seconds + while True: + code = self.sweep(dry, waits > 0) + if code is not None: + return code + waits -= 1 + self.sleep(self.cfg.poll_seconds) + + def sweep(self, dry, may_wait): + """run()'s one look at the list; None: list again (an UNKNOWN merge state, only when may_wait).""" limit = self.cfg.list_limit r = self.gh('pr', 'list', '--repo', self.repo, '--state', 'open', '--limit', str(limit), '--json', LIST) try: @@ -714,29 +748,34 @@ def run(self, dry): n = p['number'] if p.get('isDraft') or not p.get('autoMergeRequest'): continue - bad = self.failing(p) + bad, head = self.failing(p), p.get('headRefOid') or '' if bad and p.get('mergeStateStatus') == 'BEHIND' and self.cfg.update_when_base_was_red: base = p.get('baseRefName') or 'its base' - why, base_check = self.red_from_old_base(p, cache) + why, check, base_red = self.red_from_old_base(p, cache) if why is None: reds = ', '.join(sorted(self.red_checks(p))) print(f'update #{n} {(p.get("title") or "")[:70]}: required check {reds} failed on a {base} ' f'commit where it failed too, and it is green on {base} now') return self.update(n, dry) + bad = check or bad # the notice names the check that decided, and is keyed by it print(f'skip #{n}: required check {bad} failed and an update would not fix it yet: {why}') - if base_check: + if base_red: if not dry: - self.tell_red(n, bad, f'failed as on {base}', - f'{base} failed {base_check} too, so this is not this PR\'s fault: the queue ' - f'updates it by itself once {base_check} is green on {base}') + self.tell_red(n, head, bad, f'failed as on {base}', + f'{base} failed {bad} too, so this is not this PR\'s fault: the queue ' + f'updates it by itself once {bad} is green on {base}') continue elif bad: print(f'skip #{n}: required check {bad} failed (its author fixes it)') if bad: if not dry: - self.tell_red(n, bad) + self.tell_red(n, head, bad) continue if p.get('mergeStateStatus') != 'BEHIND': + if p.get('mergeStateStatus') == 'UNKNOWN' and may_wait: + print(f'#{n}: merge state UNKNOWN (GitHub is still computing it): listing again in ' + f'{self.cfg.poll_seconds} s') + return None if p.get('mergeStateStatus') not in WAITS_FOR: print(f'skip #{n}: merge state {p.get("mergeStateStatus")}, not one the queue updates or waits for') continue @@ -745,7 +784,8 @@ def run(self, dry): print(f'skip #{n}: up to date, but required check {why[0]} {why[1]} and nothing re-runs it: it ' 'cannot merge, so it does not hold the queue') if not dry: - self.tell_red(n, why[0], why[1], self.stuck_fix(why[1], p.get('baseRefName') or 'its base')) + self.tell_red(n, head, why[0], why[1], + self.stuck_fix(why[1], p.get('baseRefName') or 'its base')) continue print(f'#{n} is up to date and waiting for its checks: queue is moving') return 0 diff --git a/fork-ci/actions/pr-queue/pr_queue.test.py b/fork-ci/actions/pr-queue/pr_queue.test.py index 10c3ea9851762..200cfa30c65db 100644 --- a/fork-ci/actions/pr-queue/pr_queue.test.py +++ b/fork-ci/actions/pr-queue/pr_queue.test.py @@ -267,7 +267,7 @@ def assertOff(self, f, code, said): self.assertNotRegex(f.comments[0], r'(?i)read at\s*:', 'a queue comment must never count as a lens read') self.assertIn(said, f.comments[0]) self.assertIn('`tool ready 7`', f.comments[0]) - post = {'body': f.comments[0], 'author': {'login': 'owner'}} + post = note(f.comments[0], 't') # an author whose lens comments count: a look-alike would block (NO_HEADER) self.assertIsNone(REVIEW.lens_problem(post)) self.assertEqual(REVIEW.reviewed_head({'comments': [post], 'reviews': []}), (False, None, 'no lens comments')) @@ -367,10 +367,18 @@ def test_a_headerless_lens_comment_says_how_to_repost(self): f = self.fake(comments=[note(f'{fence}\n{LENS}: correctness · reviewer: x · read at: {self.c[:7]}\n{fence}', 't')]) code, out = self.update(f) self.assertOff(f, code, 'unclear') - self.assertIn('re-post it', f.comments[0]) + self.assertIn('Posting it again that way does not clear the old one', f.comments[0]) + self.assertIn('delete that comment (hiding it is not enough)', f.comments[0]) + self.assertIn('edit it to start with `not a lens` and a colon', f.comments[0]) self.assertIn('docs/review.md', f.comments[0]) self.assertIn('`tool ready --head : `', f.comments[0]) + def test_a_headerless_lens_comment_re_posted_right_still_blocks(self): # what the advice says, kept true + fence = '`' * 3 + bad = note(f'{fence}\n{LENS}: correctness · reviewer: x · read at: {self.c[:7]}\n{fence}', 't1') + f = self.fake(comments=[bad, lens_at('correctness', self.c, 't2')]) + self.assertOff(f, self.update(f)[0], 'unclear') + def test_failed_update_or_arm_stays_off(self): f = self.fake(fail=('update',)) self.assertOff(f, self.update(f)[0], 'update-branch failed') @@ -755,6 +763,40 @@ def test_the_plan_is_this_users_alone(self): # STATE decides what is re-armed self.assertEqual(self.update(self.fake(after=None))[0], Q.SILENT) self.assertEqual(os.stat(self.q.state).st_mode & 0o777, 0o600) + def test_a_leftover_temp_file_does_not_give_the_plan_its_mode(self): # write_text's fchmod + os.makedirs(os.path.dirname(self.q.state), mode=0o700, exist_ok=True) + with open(self.q.state + '.tmp', 'w') as f: + f.write('half a plan') + os.chmod(self.q.state + '.tmp', 0o666) + Q.write_text(self.q.state, json.dumps(self.plan())) + self.assertEqual(os.stat(self.q.state).st_mode & 0o777, 0o600) + + def test_a_link_at_the_temp_file_is_never_written_through(self): # write_text's O_NOFOLLOW + os.makedirs(os.path.dirname(self.q.state), mode=0o700, exist_ok=True) + target = os.path.join(self.w.dir, 'elsewhere') + with open(target, 'w') as f: + f.write('keep') + os.symlink(target, self.q.state + '.tmp') + with self.assertRaises(OSError): + Q.write_text(self.q.state, json.dumps(self.plan())) + with open(target) as f: + self.assertEqual(f.read(), 'keep') + self.assertFalse(self.plan_exists()) + + def test_a_link_at_the_plan_is_kept_aside_and_not_followed(self): # recover's O_NOFOLLOW + target = os.path.join(self.w.dir, 'a-plan-elsewhere') + Q.write_text(target, json.dumps(self.plan())) # this user's, 0600: followed, it would be trusted + os.makedirs(os.path.dirname(self.q.state), mode=0o700, exist_ok=True) + os.symlink(target, self.q.state) + f = self.fake(armed=False, h1=self.up) + self.q.gh = f + code, out = run_quiet(self.q.recover) + self.assertEqual((code, f.calls, f.armed), (1, [], None), out) + self.assertIn('ERROR: unreadable', out) + self.assertTrue(os.path.islink(self.q.state + '.bad')) + with open(target) as f: + self.assertEqual(json.load(f), self.plan()) + def test_a_plan_another_user_could_write_is_kept_aside_and_not_followed(self): f = self.fake(armed=False, h1=self.up) Q.write_text(self.q.state, json.dumps(self.plan())) @@ -924,7 +966,8 @@ def test_a_planted_hook_never_runs(self): self.assertFalse(os.path.exists(marker), 'a hook in the cache ran with the token in its environment') def test_a_cache_with_grafts_shallow_or_alternates_is_rebuilt(self): # each changes what the objects say - for name in ('info/grafts', 'shallow', 'objects/info/alternates', 'objects/info/http-alternates'): + for name in ('info/grafts', 'shallow', 'objects/info/alternates', 'objects/info/http-alternates', + 'info/attributes'): # attributes: `* merge=union` makes a conflict merge clean self.assertTrue(self.fetch(self.c)[0]) path = os.path.join(self.q.cache, name) os.makedirs(os.path.dirname(path), exist_ok=True) @@ -942,11 +985,13 @@ def test_a_cache_with_another_config_or_open_to_others_is_rebuilt(self): got, out = self.fetch(self.up) self.assertTrue(got, out) self.assertIn('has a config the queue did not write: rebuilding it', out) - os.chmod(self.q.cache, 0o770) - got, out = self.fetch(self.c) - self.assertTrue(got, out) - self.assertIn("is not this user's alone", out) - self.assertEqual(os.stat(self.q.cache).st_mode & 0o777, 0o700) + for mode in (0o770, 0o755): # group-writable, or only readable by others: both are rebuilt + with self.subTest(mode=oct(mode)): + os.chmod(self.q.cache, mode) + got, out = self.fetch(self.c) + self.assertTrue(got, out) + self.assertIn("is not this user's alone", out) + self.assertEqual(os.stat(self.q.cache).st_mode & 0o777, 0o700) def own(comment): @@ -989,6 +1034,20 @@ def test_a_red_that_only_a_cancelled_run_followed_still_stands(self): def test_a_green_that_a_cancelled_run_followed_is_not_red(self): self.assertIsNone(self.failing(('2026-11-09T01:00:00Z', 'SUCCESS'), ('2026-11-09T02:00:00Z', 'CANCELLED'))) + def test_a_red_a_green_re_run_then_a_cancelled_run_is_not_red(self): # r3 corr minor (:283) + self.assertIsNone(self.failing(('2026-11-09T01:00:00Z', 'FAILURE'), ('2026-11-09T02:00:00Z', 'SUCCESS'), + ('2026-11-09T03:00:00Z', 'CANCELLED'))) + self.assertEqual(self.failing(('2026-11-09T01:00:00Z', 'SUCCESS'), ('2026-11-09T02:00:00Z', 'FAILURE'), + ('2026-11-09T03:00:00Z', 'CANCELLED')), 'review-check', 'red after the green') + + def test_the_newest_run_of_a_name_counts_whichever_workflow_ran_it(self): # r3 corr minor (:279), required_now + old_red = dict(self.rollup(('2026-11-09T01:00:00Z', 'FAILURE'))[0], workflowName='a') + new_green = dict(self.rollup(('2026-11-09T02:00:00Z', 'SUCCESS'))[0], workflowName='b') + self.assertIsNone(self.q.failing({'statusCheckRollup': [old_red, new_green]})) + self.assertEqual(self.q.required_now([old_red, new_green]), {'review-check': 'ok'}) + new_red = dict(old_red, startedAt='2026-11-09T03:00:00Z') + self.assertEqual(self.q.failing({'statusCheckRollup': [new_red, new_green]}), 'review-check') + def test_a_check_that_is_not_required_is_ignored(self): self.assertIsNone(self.failing(('2026-11-09T02:00:00Z', 'FAILURE'), name='lint')) @@ -998,11 +1057,12 @@ def test_two_red_checks_name_the_first_by_name(self): self.assertEqual(self.q.failing({'statusCheckRollup': rollup}), 'build') def sweep(self, state='BEHIND', runs=(('2026-11-09T02:00:00Z', 'FAILURE'),), comments=(), head=None, dry=False, - fail=(), name='review-check'): + fail=(), name='review-check', pushed=None): + """head: the PR's head in the list; pushed: its head when tell_red reads it (a push in between).""" self.posted, self.updated = [], [] listed = [{'number': 7, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, 'mergeStateStatus': state, 'createdAt': '2026-11-07T00:00:00Z', 'statusCheckRollup': self.rollup(*runs, name=name), - 'baseRefName': 'main'}] + 'headRefOid': head or self.H, 'baseRefName': 'main'}] def gh(*a): if a[:2] == ('pr', 'list'): @@ -1010,7 +1070,7 @@ def gh(*a): if a[:2] == ('pr', 'view'): if 'view' in fail: return failed('boom') - return ok(json.dumps({'headRefOid': head or self.H, 'comments': [own(c) for c in comments]})) + return ok(json.dumps({'headRefOid': pushed or head or self.H, 'comments': [own(c) for c in comments]})) if a[:2] == ('pr', 'comment'): self.posted.append(a[-1]) return failed('boom') if 'comment' in fail else ok() @@ -1057,6 +1117,13 @@ def test_it_is_told_once_per_head(self): self.sweep(comments=[first], head='e5' * 20) self.assertEqual(len(self.posted), 1, 'a new head that is red is a new message') + def test_a_push_after_the_list_posts_nothing(self): # r3 corr minor (:356): the red is not known on the new head + new = 'e5' * 20 + code = self.sweep(pushed=new) + self.assertEqual((code, self.posted), (0, [])) + self.sweep(head=new) + self.assertIn(f'is red on {new[:12]}', self.posted[0], 'the next run, listing the new head, tells it') + def test_only_the_queues_own_comment_counts_as_told(self): # anyone can post a copy of the mark self.sweep() first = self.posted[0] @@ -1081,7 +1148,7 @@ def test_an_older_red_no_longer_skips_the_pr(self): def test_the_message_can_never_count_as_a_lens(self): self.sweep() - post = {'body': self.posted[0], 'author': {'login': 'owner'}} + post = note(self.posted[0], 't') # an author whose lens comments count: a look-alike would block (NO_HEADER) self.assertIsNone(REVIEW.lens_problem(post)) self.assertEqual(REVIEW.reviewed_head({'comments': [post], 'reviews': []}), (False, None, 'no lens comments')) @@ -1166,11 +1233,32 @@ def test_a_re_run_of_the_cancelled_check_holds_the_queue_again(self): # the ne self.assertIn('#1 is up to date and waiting', out) def test_green_or_running_checks_still_hold_the_queue(self): - for first in (self.checks(), self.checks(review_check='IN_PROGRESS'), []): + for first in (self.checks(), self.checks(review_check='IN_PROGRESS')): with self.subTest(first=len(first)): code, out = self.sweep(first) self.assertEqual((code, self.updated, self.posted, self.asked), (0, [], [], []), out) + def test_a_cancelled_check_waits_while_a_workflow_runs_on_the_head(self): # r3 corr minor (:322) + # cancel-in-progress: the newer run of the workflow is queued and has no job yet, so it is not in the rollup + for kw in ({'runs': '[{"status": "queued", "workflowName": "ci"}]'}, {'runs_fail': True}): + with self.subTest(**kw): + code, out = self.sweep(self.checks(build='CANCELLED'), **kw) + self.assertEqual((code, self.updated, self.posted, self.asked), (0, [], [], [self.H1]), out) + self.assertIn('required check build ended CANCELLED, and which workflows still run', out) + + def test_a_head_no_check_ever_reported_on_is_told_once_its_workflows_ended(self): # r3 corr minor (:328) + # every job skipped itself or the workflow ended before any required job: it never merges, so it must not hold + code, out = self.sweep([], runs='[{"status": "completed", "workflowName": "ci"}]') + self.assertEqual((self.asked, self.updated, len(self.posted)), ([self.H1], [2], 1), out) + self.assertIn('required check build never reported', self.posted[0]) + for kw in ({'runs': '[]'}, {'runs': '[{"status": "in_progress", "workflowName": "ci"}]'}, {'runs_fail': True}): + with self.subTest(**kw): # nothing ran yet, or still runs, or unknown: it may still get its checks + code, out = self.sweep([], **kw) + self.assertEqual((code, self.updated, self.posted), (0, [], []), out) + self.assertIn('is unknown: waiting', out) + code, out = self.sweep([], runs='[]') + self.assertIn('no check and no workflow run on its head yet: waiting', out) + def test_a_required_check_that_never_reports_is_told_once_nothing_runs(self): code, out = self.sweep(self.checks(test=None), runs='[{"status": "completed", "workflowName": "ci"}]') self.assertEqual((self.asked, self.updated, len(self.posted)), ([self.H1], [2], 1), out) @@ -1199,6 +1287,43 @@ def test_a_dry_run_posts_nothing_and_says_what_it_skips(self): self.assertIn('skip #1: up to date, but required check build ended CANCELLED', out) +class UnknownMergeState(unittest.TestCase): + """GitHub shows UNKNOWN for a few seconds after a push to the base, when the push's queue run starts: the older PR + is listed again until its state is known, so a younger one is not updated ahead of it (r3 corr minor, :518).""" + def setUp(self): + d = tempfile.mkdtemp(prefix='pr-queue-unknown-') + self.addCleanup(shutil.rmtree, d, True) + self.sleeps = [] + self.q = make_queue(d, sleeps=self.sleeps) + + def sweep(self, unknown_lists): + self.updated, lists = [], [] + + def gh(*a): + if a[:2] == ('pr', 'list'): + lists.append(1) + first = 'UNKNOWN' if len(lists) <= unknown_lists else 'BEHIND' + return ok(json.dumps([{'number': n, 'title': 't', 'isDraft': False, 'autoMergeRequest': {'m': 1}, + 'mergeStateStatus': first if n == 1 else 'BEHIND', 'statusCheckRollup': [], + 'createdAt': f'2026-11-07T00:00:0{n}Z', 'headRefOid': f'{n}' * 40, + 'baseRefName': 'main'} for n in (1, 2)])) + raise AssertionError(a) + self.q.gh = gh + self.q.update = lambda n, d: self.updated.append(n) or 0 + return run_quiet(self.q.run, False) + + def test_an_older_pr_in_unknown_state_is_listed_again_before_a_younger_one_moves(self): + code, out = self.sweep(unknown_lists=2) + self.assertEqual((code, self.updated, self.sleeps), (0, [1], [CFG.poll_seconds] * 2), out) + self.assertIn('#1: merge state UNKNOWN', out) + + def test_an_unknown_state_that_stays_is_passed_over_after_wait_seconds(self): + tries = CFG.wait_seconds // CFG.poll_seconds + code, out = self.sweep(unknown_lists=tries + 5) + self.assertEqual((code, self.updated, len(self.sleeps)), (0, [2], tries), out) + self.assertIn('skip #1: merge state UNKNOWN', out) + + class RedChecksWithTheBaseRule(RedChecks): """Every RedChecks case again with update_when_base_was_red on: a red whose run names no tested base (its job has no tested-base step) is the PR's own, as with the rule off.""" @@ -1349,6 +1474,27 @@ def test_a_red_of_its_own_beside_a_base_still_red_is_its_authors(self): # r3 m self.assertNotIn('not this PR', self.posted[0]) self.assertIn('Fix the cause and push', self.posted[0]) + def test_the_notice_names_the_prs_own_red_beside_a_base_red_that_sorts_first(self): # r4 corr minor (:737) + main = {k: {**v, 'test': 'SUCCESS'} for k, v in self.main_of(tip='FAILURE').items()} # build: the base's + code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'FAILURE', self.M1)]}, main) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'required check test is red on {self.H[:12]}', self.posted[0]) + self.assertIn(f'skip #7: required check test failed and an update would not fix it yet', out) + + def test_the_wait_notice_is_told_again_when_the_check_it_names_turns_green_on_the_base(self): # r4 (:729/:361) + runs = {'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'FAILURE', self.M1)]} + both = self.main_of(tip='FAILURE', other={'test': 'FAILURE'}) # build and test red on main + self.one(runs, both) + first = self.posted[0] + self.assertIn('once build is green on main', first) + self.one(runs, both, comments=[first]) + self.assertEqual(self.posted, [], 'told once per head and check') + fixed = self.main_of(other={'test': 'FAILURE'}) # main fixed build; test is still red there + code, out = self.one(runs, fixed, comments=[first]) + self.assertEqual((self.updated, len(self.posted)), ([], 1), out) + self.assertIn(f'required check test failed as on main on {self.H[:12]}', self.posted[0]) + self.assertIn('once test is green on main', self.posted[0]) + def test_the_wait_notice_names_the_check_that_is_red_on_the_base(self): # r3 minor: not just the first red main = self.main_of(other={'test': 'FAILURE'}) # build: red on M1, green now; test: red on M1 and now code, out = self.one({'build': [(self.RUN, 'FAILURE', self.M1)], 'test': [(self.RUN, 'FAILURE', self.M1)]}, main) From 2af1134087a77dda0297b262759c9efb2b17c86a Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:20:57 +0100 Subject: [PATCH 23/27] Fork: weekly check that the repository's interaction limit never lapses (#30) * Fork: weekly check that the repository's interaction limit never lapses fork-interaction-limit.sh check|renew|report reads the limit (REST only; GraphQL's interactionAbility reads null for "no limit" and "may not see"), renews it for six months and reads it back, and opens/updates/closes one issue. The weekly workflow reads with a separate Administration read-only secret, because GitHub's workflow token gets 403 on interaction limits. fork-gh-guard.sh now lets one named script run on a person's machine (FORK_GH_LOCAL=