Public: anyone can read this; the crew can change it.
A release is never held because main moved: the build accepts a green check of a descendant of the tagged commit #3968
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3966-a-release-is-never-held-because-main-mov → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +269
−17
README.md +1 −1
| @@ -1915,7 +1915,7 @@ Below them the run page has a per-look web-vitals table: LCP, CLS and TBT (`webv | ||
| 1915 | 1915 | |
| 1916 | 1916 | While the default branch's own last check is red, the forge holds its pulls' checks queued rather than fail each one on the same test (`src/checks/red_main.ts`): the vitals carry one card naming the failing test and how many checks wait, and main going green releases them. A check a person asks for again runs at once, and no check is held more than 20 minutes, so the pull that fixes main can always show green. |
| 1917 | 1917 | |
| 1918 | `GET /api/v1/repos/:owner/:name/commits/:oid/check` answers the default branch's own check of one full commit id, `{commit, state, runId, summary, finishedAt}` with `state` `ok`, `failed`, `pending`, `superseded` (every check of it was cancelled, as when main moved on before it ran, and none will follow) or `none`, to whoever may read the repository's runs (`src/checks/commit_check.ts`). It is half of the forge's release gate: the release workflow builds only once it reads `ok` for the tagged commit, and the build boots the image it made (`docs/ops/ROLL.md`, "The gate of record"). | |
| 1918 | `GET /api/v1/repos/:owner/:name/commits/:oid/check` answers the default branch's check of one full commit id, `{commit, checkedCommit, state, runId, summary, finishedAt}` with `state` `ok`, `failed`, `pending`, `superseded` (every check of it was cancelled, as when main moved on before it ran, and none will follow) or `none`, to whoever may read the repository's runs (`src/checks/commit_check.ts`). When the commit's own check is `superseded` or `none`, the newest green (or else running) check of a later main commit that contains it answers instead, and `checkedCommit` names that commit: main moving on never holds a release (#3966). It is half of the forge's release gate: the release workflow builds only once it reads `ok` for the tagged commit, and the build boots the image it made (`docs/ops/ROLL.md`, "The gate of record"). | |
| 1919 | 1919 | |
| 1920 | 1920 | A test file the shard gate clears because it passed alone is still a failure: when the default branch's own check log says `fails only beside others: <files> (shard-mates: <files>)`, the runner opens one issue per file (at most 5 per run, the rest named in the log as not filed) or comments on the one already open. The issue is authored as the repository owner and its body says it was filed by the check runner. A pull or other branch's check files nothing. |
| 1921 | 1921 |
docs/ops/ROLL.md +6
| @@ -156,6 +156,12 @@ A release ships on two proofs, and nothing else holds it: | ||
| 156 | 156 | decides; a cancelled one is passed over when another check of the commit |
| 157 | 157 | stands (a restart queues it again), and is `superseded` when none does: |
| 158 | 158 | main moved on while it waited, and nothing will check that commit again. |
| 159 | Main moving does not hold a release (#3966): when the commit's own check | |
| 160 | is `superseded` or `none`, the newest of main's checks of a later commit | |
| 161 | that contains it (`git merge-base --is-ancestor`) answers instead — `ok` | |
| 162 | when green, `pending` while running — since that full check ran the | |
| 163 | tagged tree plus what landed after. A red later check is no verdict on | |
| 164 | the tag and is passed over. `checkedCommit` names whose check answered. | |
| 159 | 165 | `release.yml`'s first gated step polls it every 30 s for up to two hours |
| 160 | 166 | and builds only on `ok`; `failed` stops the release with the run's link, |
| 161 | 167 | `superseded` stops it at once, and `none` stops it after ten minutes (a |
src/actions/runs.ts +16 −6
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | * the system's own voice and takes no actor. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { commitCheck, isCommitId, type CommitCheck } from "../checks/commit_check.ts"; | |
| 11 | import { isCommitId, releaseCheck, type CommitCheck } from "../checks/commit_check.ts"; | |
| 12 | import { hasCommit, isAncestor } from "../git/local.ts"; | |
| 13 | import { repoDir } from "../git/repos.ts"; | |
| 12 | 14 | import { mayViewId } from "../core/sealed.ts"; |
| 13 | 15 | import { requireVerified } from "./verify.ts"; |
| 14 | 16 | import { requireAbility } from "./roles.ts"; |
| @@ -153,17 +155,25 @@ export function listRepoRuns( | ||
| 153 | 155 | } |
| 154 | 156 | |
| 155 | 157 | /** |
| 156 | * The default branch's own check of one commit (`checks/commit_check.ts`), | |
| 157 | * for whoever may read the repository's runs: what a release waits on. | |
| 158 | * The default branch's check that covers one commit (`checks/commit_check.ts` | |
| 159 | * `releaseCheck`), for whoever may read the repository's runs: what a | |
| 160 | * release waits on — the commit's own check, or a later main commit's that | |
| 161 | * contains it when main moved on before the commit's own check ran (#3966). | |
| 158 | 162 | */ |
| 159 | export function checkOfCommit( | |
| 163 | export async function checkOfCommit( | |
| 160 | 164 | app: App, |
| 161 | 165 | actor: User | undefined, |
| 162 | 166 | repo: Repo, |
| 163 | 167 | oid: string, |
| 164 | ): Result<CommitCheck, "not_found"> { | |
| 168 | ): Promise<Result<CommitCheck, "not_found">> { | |
| 165 | 169 | if (!isCommitId(oid) || !mayReadRuns(app, actor, repo)) return err("not_found"); |
| 166 | return ok(commitCheck(app.db, repo.id, repo.defaultBranch, oid)); | |
| 170 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 171 | return ok( | |
| 172 | await releaseCheck(app.db, repo.id, repo.defaultBranch, oid, { | |
| 173 | has: () => hasCommit(dir, oid), | |
| 174 | contains: (descendant) => isAncestor(dir, oid, descendant), | |
| 175 | }), | |
| 176 | ); | |
| 167 | 177 | } |
| 168 | 178 | |
| 169 | 179 | /** A run by id, only if it belongs to a repository the actor can see. */ |
src/checks/commit_check.ts +90 −2
| @@ -16,6 +16,14 @@ | ||
| 16 | 16 | * moved on while it waited (`superseded by <sha>`, `checks/service.ts`) or |
| 17 | 17 | * a person stopped it — and the answer is `superseded`, never `none`, so a |
| 18 | 18 | * release on that commit stops at once rather than waiting out its budget. |
| 19 | * | |
| 20 | * {@link releaseCheck} is what a release reads (#3966): the tagged commit's | |
| 21 | * own check when it has a verdict, and otherwise — main moved on and | |
| 22 | * cancelled it, or the commit landed inside a batch that was checked only | |
| 23 | * at its head — the newest of main's checks of a later commit that contains | |
| 24 | * it. That check ran the full suite on the tagged tree plus what landed | |
| 25 | * after, so its green covers the tag. `checkedCommit` names whose check | |
| 26 | * answered. | |
| 19 | 27 | */ |
| 20 | 28 | |
| 21 | 29 | import type { Database } from "bun:sqlite"; |
| @@ -27,6 +35,8 @@ export type CommitCheckState = "ok" | "failed" | "pending" | "superseded" | "non | ||
| 27 | 35 | |
| 28 | 36 | export interface CommitCheck { |
| 29 | 37 | readonly commit: string; |
| 38 | /** Whose check answered: `commit` itself, or a later main commit containing it. */ | |
| 39 | readonly checkedCommit: string; | |
| 30 | 40 | /** |
| 31 | 41 | * `ok` passed, `failed` failed, `pending` queued or running, `superseded` |
| 32 | 42 | * every check of it was cancelled and none will follow, `none` never checked. |
| @@ -59,12 +69,90 @@ export function commitCheck( | ||
| 59 | 69 | ORDER BY state <> 'cancelled' DESC, id DESC LIMIT 1`, |
| 60 | 70 | ) |
| 61 | 71 | .get(repoId, branch, oid, WINDOW); |
| 62 | if (row === null) return { commit: oid, state: "none", runId: null, summary: "", finishedAt: null }; | |
| 72 | if (row === null) | |
| 73 | return { commit: oid, checkedCommit: oid, state: "none", runId: null, summary: "", finishedAt: null }; | |
| 63 | 74 | const state: CommitCheckState = |
| 64 | 75 | row.state === "ok" || row.state === "failed" |
| 65 | 76 | ? row.state |
| 66 | 77 | : row.state === "cancelled" |
| 67 | 78 | ? "superseded" |
| 68 | 79 | : "pending"; |
| 69 | return { commit: oid, state, runId: row.id, summary: row.summary, finishedAt: row.finished_at }; | |
| 80 | return { | |
| 81 | commit: oid, | |
| 82 | checkedCommit: oid, | |
| 83 | state, | |
| 84 | runId: row.id, | |
| 85 | summary: row.summary, | |
| 86 | finishedAt: row.finished_at, | |
| 87 | }; | |
| 88 | } | |
| 89 | ||
| 90 | /** How many later commits main checked are asked whether they contain the tag. */ | |
| 91 | export const DESCENDANTS = 10; | |
| 92 | ||
| 93 | /** | |
| 94 | * The check a release on `oid` waits on. The commit's own `ok`, `failed` or | |
| 95 | * `pending` answers as it is. When it is `superseded` or `none`, main's | |
| 96 | * checks of later commits (newest first, at most {@link DESCENDANTS} | |
| 97 | * distinct commits, each by its own newest non-cancelled check) are asked | |
| 98 | * in turn: the first green one that `contains` the commit answers `ok`; a | |
| 99 | * containing one still running answers `pending`, so the release waits for | |
| 100 | * it. A red one is not a verdict on the tag and is passed over; when nothing | |
| 101 | * green or running contains the commit the commit's own answer stands. | |
| 102 | * | |
| 103 | * `history.contains(descendant)` says whether `oid` is reachable from | |
| 104 | * `descendant` (`git merge-base --is-ancestor`); `history.has()` says whether | |
| 105 | * `oid` is a commit in the repository at all, and is asked once before any | |
| 106 | * `contains`, so an oid the repository never saw costs one git process, not | |
| 107 | * one per descendant (the endpoint is unauthenticated on a public repo). | |
| 108 | * Both are passed in so this stays plain data. | |
| 109 | */ | |
| 110 | export async function releaseCheck( | |
| 111 | db: Database, | |
| 112 | repoId: string, | |
| 113 | branch: string, | |
| 114 | oid: string, | |
| 115 | history: { has: () => Promise<boolean>; contains: (descendant: string) => Promise<boolean> }, | |
| 116 | ): Promise<CommitCheck> { | |
| 117 | const own = commitCheck(db, repoId, branch, oid); | |
| 118 | if (own.state !== "superseded" && own.state !== "none") return own; | |
| 119 | const rows = db | |
| 120 | .query< | |
| 121 | { id: string; commit_oid: string; state: string; summary: string; finished_at: string | null }, | |
| 122 | [string, string, string, number, string] | |
| 123 | >( | |
| 124 | `SELECT id, commit_oid, state, summary, finished_at | |
| 125 | FROM (SELECT id, kind, issue_id, env, commit_oid, state, summary, finished_at | |
| 126 | FROM run WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?4) | |
| 127 | WHERE kind = 'check' AND issue_id IS NULL AND env = ?2 | |
| 128 | AND commit_oid IS NOT NULL AND commit_oid NOT IN ('', ?3) AND state <> 'cancelled' | |
| 129 | AND id > ?5 | |
| 130 | ORDER BY id DESC`, | |
| 131 | ) | |
| 132 | .all(repoId, branch, oid, WINDOW, own.runId ?? ""); | |
| 133 | const seen = new Set<string>(); | |
| 134 | let waiting: CommitCheck | null = null; | |
| 135 | let known: boolean | null = null; | |
| 136 | for (const row of rows) { | |
| 137 | if (seen.has(row.commit_oid)) continue; | |
| 138 | seen.add(row.commit_oid); | |
| 139 | if (seen.size > DESCENDANTS) break; | |
| 140 | if (row.state === "failed") continue; | |
| 141 | const pending = row.state !== "ok"; | |
| 142 | if (pending && waiting !== null) continue; | |
| 143 | known ??= await history.has(); | |
| 144 | if (!known) return own; | |
| 145 | if (!(await history.contains(row.commit_oid))) continue; | |
| 146 | const answer: CommitCheck = { | |
| 147 | commit: oid, | |
| 148 | checkedCommit: row.commit_oid, | |
| 149 | state: pending ? "pending" : "ok", | |
| 150 | runId: row.id, | |
| 151 | summary: row.summary, | |
| 152 | finishedAt: row.finished_at, | |
| 153 | }; | |
| 154 | if (!pending) return answer; | |
| 155 | waiting = answer; | |
| 156 | } | |
| 157 | return waiting ?? own; | |
| 70 | 158 | } |
src/git/local.ts +7
| @@ -861,6 +861,13 @@ export async function isAncestor( | ||
| 861 | 861 | return out.ok; |
| 862 | 862 | } |
| 863 | 863 | |
| 864 | /** Is `oid` a commit object in the repository? One `git cat-file -e`. */ | |
| 865 | export async function hasCommit(dir: string, oid: string): Promise<boolean> { | |
| 866 | if (!isSafeRef(oid)) return false; | |
| 867 | const out = await runLocal(["cat-file", "-e", `${oid}^{commit}`], { cwd: dir }); | |
| 868 | return out.ok; | |
| 869 | } | |
| 870 | ||
| 864 | 871 | /** `rev-list --left-right --count a...b` — what each side has alone. */ |
| 865 | 872 | export async function aheadBehind( |
| 866 | 873 | dir: string, |
src/http/api.ts +4 −4
| @@ -2740,12 +2740,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2740 | 2740 | }); |
| 2741 | 2741 | }); |
| 2742 | 2742 | |
| 2743 | // The default branch's own check of a full commit id: what a release | |
| 2744 | // waits on before it builds (#2794). Never cached: `pending` turns. | |
| 2745 | router.add("GET", "/api/v1/repos/:owner/:name/commits/:oid/check", (ctx) => { | |
| 2743 | // The default branch's check covering a full commit id: what a release | |
| 2744 | // waits on before it builds (#2794, #3966). Never cached: `pending` turns. | |
| 2745 | router.add("GET", "/api/v1/repos/:owner/:name/commits/:oid/check", async (ctx) => { | |
| 2746 | 2746 | const repo = loadRepo(ctx); |
| 2747 | 2747 | if (!repo) return notFound(); |
| 2748 | const found = checkOfCommit(ctx.app, ctx.auth.user, repo, ctx.params.oid ?? ""); | |
| 2748 | const found = await checkOfCommit(ctx.app, ctx.auth.user, repo, ctx.params.oid ?? ""); | |
| 2749 | 2749 | if (!found.ok) return notFound(); |
| 2750 | 2750 | return json(found.value, 200, { "cache-control": "no-store" }); |
| 2751 | 2751 | }); |
test/commit-check.test.ts +145 −4
| @@ -3,18 +3,23 @@ | ||
| 3 | 3 | * (#2794): only main's own check counts (never a pull's fast tier, never |
| 4 | 4 | * another branch), the newest decides, a cancelled check says nothing, and |
| 5 | 5 | * the API answers it by full commit id to whoever may read the runs. |
| 6 | * | |
| 7 | * A release is never held because main moved (#3966): when the tagged | |
| 8 | * commit's own check was cancelled, or main never checked it alone, a green | |
| 9 | * check of a later main commit that contains it answers for it. | |
| 6 | 10 | */ |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 8 | 12 | import type { Database } from "bun:sqlite"; |
| 9 | 13 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 14 | import { tmpdir } from "node:os"; |
| 11 | 15 | import { join } from "node:path"; |
| 12 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 17 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 14 | import { commitCheck } from "../src/checks/commit_check.ts"; | |
| 18 | import { commitCheck, DESCENDANTS, releaseCheck } from "../src/checks/commit_check.ts"; | |
| 15 | 19 | import { migrations } from "../src/core/schema.ts"; |
| 16 | 20 | import { cancelRun, claimRun, createRun, finishRun, requeueCheck } from "../src/core/runs.ts"; |
| 17 | import { createUser } from "../src/core/users.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | 23 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; |
| 19 | 24 | import { openDb } from "../src/kernel/db.ts"; |
| 20 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -28,7 +33,7 @@ function check(db: Database, repoId: string, commit: string, opts: { env?: strin | ||
| 28 | 33 | repoId, |
| 29 | 34 | kind: "check", |
| 30 | 35 | env: opts.env ?? "main", |
| 31 | commitOid: OID(commit), | |
| 36 | commitOid: commit.length === 40 ? commit : OID(commit), | |
| 32 | 37 | reason: "push", |
| 33 | 38 | ...(opts.issueId === undefined ? {} : { issueId: opts.issueId }), |
| 34 | 39 | }); |
| @@ -52,6 +57,7 @@ describe("commitCheck", () => { | ||
| 52 | 57 | test("a commit main never checked is none", () => { |
| 53 | 58 | expect(commitCheck(db, "r1", "main", OID("a"))).toEqual({ |
| 54 | 59 | commit: OID("a"), |
| 60 | checkedCommit: OID("a"), | |
| 55 | 61 | state: "none", |
| 56 | 62 | runId: null, |
| 57 | 63 | summary: "", |
| @@ -109,10 +115,105 @@ describe("commitCheck", () => { | ||
| 109 | 115 | }); |
| 110 | 116 | }); |
| 111 | 117 | |
| 118 | describe("releaseCheck", () => { | |
| 119 | let db: Database; | |
| 120 | beforeEach(() => { | |
| 121 | db = openDb(":memory:", migrations); | |
| 122 | db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'trevato', 't@x', '')"); | |
| 123 | db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'forge')"); | |
| 124 | }); | |
| 125 | /** `t` (the tag) is in every commit but `x`, which is on no line with it. */ | |
| 126 | const contains = async (descendant: string) => descendant !== OID("x"); | |
| 127 | const has = async () => true; | |
| 128 | const asked = (oid: string) => releaseCheck(db, "r1", "main", OID(oid), { has, contains }); | |
| 129 | ||
| 130 | test("the tagged commit's own verdict answers as it is", async () => { | |
| 131 | const own = check(db, "r1", "t"); | |
| 132 | finish(db, check(db, "r1", "u").id, "ok"); | |
| 133 | expect(await asked("t")).toMatchObject({ state: "pending", checkedCommit: OID("t"), runId: own.id }); | |
| 134 | claimRun(db, own.id); | |
| 135 | finishRun(db, own.id, { state: "failed", summary: "check failed: 1 test failed" }); | |
| 136 | expect(await asked("t")).toMatchObject({ state: "failed", checkedCommit: OID("t") }); | |
| 137 | }); | |
| 138 | ||
| 139 | test("main moved on twice: the tag's cancelled check is answered by the descendant's green one", async () => { | |
| 140 | const tagged = check(db, "r1", "t"); | |
| 141 | cancelRun(db, tagged.id, `superseded by ${OID("u").slice(0, 7)}`); | |
| 142 | const first = check(db, "r1", "u"); | |
| 143 | cancelRun(db, first.id, `superseded by ${OID("v").slice(0, 7)}`); | |
| 144 | const second = check(db, "r1", "v"); | |
| 145 | expect(await asked("t")).toMatchObject({ commit: OID("t"), checkedCommit: OID("v"), state: "pending", runId: second.id }); | |
| 146 | finish(db, second.id, "ok"); | |
| 147 | expect(await asked("t")).toMatchObject({ | |
| 148 | commit: OID("t"), | |
| 149 | checkedCommit: OID("v"), | |
| 150 | state: "ok", | |
| 151 | runId: second.id, | |
| 152 | summary: "check passed in 368s", | |
| 153 | }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a commit main never checked alone, inside a checked batch, is answered by the batch's check", async () => { | |
| 157 | finish(db, check(db, "r1", "v").id, "ok"); | |
| 158 | expect(await asked("t")).toMatchObject({ state: "ok", checkedCommit: OID("v") }); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a later commit that does not contain the tag, or a red one, does not answer for it", async () => { | |
| 162 | const tagged = check(db, "r1", "t"); | |
| 163 | cancelRun(db, tagged.id, "superseded by xxxxxxx"); | |
| 164 | finish(db, check(db, "r1", "x").id, "ok"); | |
| 165 | expect(await asked("t")).toMatchObject({ state: "superseded", checkedCommit: OID("t"), runId: tagged.id }); | |
| 166 | finish(db, check(db, "r1", "v").id, "failed"); | |
| 167 | expect((await asked("t")).state).toBe("superseded"); | |
| 168 | expect((await asked("y")).state).toBe("none"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a green check older than the tag's own is never read as covering it", async () => { | |
| 172 | finish(db, check(db, "r1", "v").id, "ok"); | |
| 173 | const tagged = check(db, "r1", "t"); | |
| 174 | cancelRun(db, tagged.id, "stopped"); | |
| 175 | expect((await asked("t")).state).toBe("superseded"); | |
| 176 | }); | |
| 177 | ||
| 178 | test("the newest green descendant answers before an older one still running", async () => { | |
| 179 | cancelRun(db, check(db, "r1", "t").id, "superseded"); | |
| 180 | check(db, "r1", "u"); | |
| 181 | const green = check(db, "r1", "v"); | |
| 182 | finish(db, green.id, "ok"); | |
| 183 | expect(await asked("t")).toMatchObject({ state: "ok", runId: green.id }); | |
| 184 | }); | |
| 185 | ||
| 186 | test("an oid the repository does not have answers none without asking any descendant", async () => { | |
| 187 | for (const c of "uvw") finish(db, check(db, "r1", c).id, "ok"); | |
| 188 | let hasCalls = 0; | |
| 189 | let containsCalls = 0; | |
| 190 | const answer = await releaseCheck(db, "r1", "main", OID("z"), { | |
| 191 | has: async () => (hasCalls++, false), | |
| 192 | contains: async () => (containsCalls++, true), | |
| 193 | }); | |
| 194 | expect(answer.state).toBe("none"); | |
| 195 | expect(hasCalls).toBe(1); | |
| 196 | expect(containsCalls).toBe(0); | |
| 197 | }); | |
| 198 | ||
| 199 | test("at most DESCENDANTS distinct later commits are asked", async () => { | |
| 200 | for (let i = 0; i < DESCENDANTS + 5; i++) finish(db, check(db, "r1", i.toString(16).padStart(40, "0")).id, "ok"); | |
| 201 | let containsCalls = 0; | |
| 202 | const answer = await releaseCheck(db, "r1", "main", OID("z"), { | |
| 203 | has: async () => true, | |
| 204 | contains: async () => (containsCalls++, false), | |
| 205 | }); | |
| 206 | expect(answer.state).toBe("none"); | |
| 207 | expect(containsCalls).toBe(DESCENDANTS); | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 112 | 211 | describe("GET /api/v1/repos/:owner/:name/commits/:oid/check", () => { |
| 113 | 212 | let dataDir: string; |
| 114 | 213 | let app: App; |
| 115 | 214 | let http: ForgeHttp; |
| 215 | let alice: User; | |
| 216 | let forge: Repo; | |
| 116 | 217 | |
| 117 | 218 | const get = async (path: string) => { |
| 118 | 219 | const res = await http.handle(new Request(`http://forge.test${path}`)); |
| @@ -132,9 +233,11 @@ describe("GET /api/v1/repos/:owner/:name/commits/:oid/check", () => { | ||
| 132 | 233 | adminIfFirst: true, |
| 133 | 234 | }); |
| 134 | 235 | if (!made.ok) throw new Error(String(made.error)); |
| 236 | alice = made.value; | |
| 135 | 237 | for (const [name, visibility] of [["forge", "public"], ["hidden", "private"]] as const) { |
| 136 | 238 | const repo = await createRepo(app, made.value, { name, visibility }); |
| 137 | 239 | if (!repo.ok) throw new Error(String(repo.error)); |
| 240 | if (name === "forge") forge = repo.value; | |
| 138 | 241 | await commitToBranch(app, made.value, repo.value, { |
| 139 | 242 | message: "Start", |
| 140 | 243 | files: [{ path: "README.md", content: `# ${name}\n` }], |
| @@ -162,4 +265,42 @@ describe("GET /api/v1/repos/:owner/:name/commits/:oid/check", () => { | ||
| 162 | 265 | expect((await get(`/api/v1/repos/alice/forge/commits/${"E".repeat(40)}/check`)).status).toBe(404); |
| 163 | 266 | expect((await get(`/api/v1/repos/alice/hidden/commits/${OID("e")}/check`)).status).toBe(404); |
| 164 | 267 | }); |
| 268 | ||
| 269 | test("a tag whose check two landings cancelled builds on the descendant's green check, never on a stranger's", async () => { | |
| 270 | const repo = forge; | |
| 271 | const land = async (path: string, branch = "main") => { | |
| 272 | const done = await commitToBranch(app, alice, repo, { | |
| 273 | message: `Land ${path}`, | |
| 274 | branch, | |
| 275 | files: [{ path, content: `${path}\n` }], | |
| 276 | }); | |
| 277 | if (!done.ok) throw new Error(String(done.error)); | |
| 278 | return done.value.commitOid; | |
| 279 | }; | |
| 280 | const tag = await land("release.txt"); | |
| 281 | const tagged = check(app.db, repo.id, tag); | |
| 282 | const one = await land("one.txt"); | |
| 283 | cancelRun(app.db, tagged.id, `superseded by ${one.slice(0, 7)}`); | |
| 284 | const first = check(app.db, repo.id, one); | |
| 285 | const two = await land("two.txt"); | |
| 286 | cancelRun(app.db, first.id, `superseded by ${two.slice(0, 7)}`); | |
| 287 | const second = check(app.db, repo.id, two); | |
| 288 | const read = async () => JSON.parse((await get(`/api/v1/repos/alice/forge/commits/${tag}/check`)).body); | |
| 289 | expect(await read()).toMatchObject({ commit: tag, checkedCommit: two, state: "pending" }); | |
| 290 | finish(app.db, second.id, "ok"); | |
| 291 | expect(await read()).toMatchObject({ commit: tag, checkedCommit: two, state: "ok", runId: second.id }); | |
| 292 | ||
| 293 | // A green check of a commit on another line does not contain the tag. | |
| 294 | const side = await land("side.txt", "side"); | |
| 295 | const lone = check(app.db, repo.id, side); | |
| 296 | finish(app.db, lone.id, "ok"); | |
| 297 | const sideTag = await land("side-two.txt", "side"); | |
| 298 | const sideCheck = check(app.db, repo.id, sideTag); | |
| 299 | cancelRun(app.db, sideCheck.id, "superseded"); | |
| 300 | finish(app.db, check(app.db, repo.id, await land("three.txt")).id, "ok"); | |
| 301 | expect(JSON.parse((await get(`/api/v1/repos/alice/forge/commits/${sideTag}/check`)).body)).toMatchObject({ | |
| 302 | checkedCommit: sideTag, | |
| 303 | state: "superseded", | |
| 304 | }); | |
| 305 | }); | |
| 165 | 306 | }); |
new above
offline
Medium: anyone can reach this unauthenticated on a public repo, and an oid that is not in the repo (
none) still costs up to 50git merge-basespawns per request. Before the loop, returnownwhenoidis not a commit in the repo, and add a test wherecontainsis never called for an unknown oid.