Public: anyone can read this; the crew can change it.

One release gate: the forge's own full check on the release commit plus a proof the built image boots; the Forgejo runner's copy is retired #2797

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/2794-one-release-gate-the-forge-s-own-full-ch → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/2794-one-release-gate-the-forge-s-own-full-ch was merged into main as c8fb376.

Review

Sign in to review this change.
10 files changed +402 −138
.forgejo/workflows/check.yml deleted −134
@@ -1,134 +0,0 @@
1 name: check
2 # The gate: the check forge.json declares — zero type errors and a green
3 # suite — on every push to main and every pull request, before release.yml
4 # ever builds an image. Runs on the
5 # platform's isolated, unprivileged runner pool (host mode: bun + git baked
6 # in, no docker daemon).
7 #
8 # Typecheck (tsc --noEmit) is intentionally NOT run on this isolated Actions
9 # runner. Empirically (#386/#388/#390/#392) full-project tsc SIGKILLs after
10 # ~17m thrash (exit 137) at --max-old-space-size=1024 or 1536: the isolated
11 # Actions cgroup is too small. Locally the same tsc finishes in ~12s with
12 # peak RSS ~772 MiB at heap 1024. forge.json check.limits.memory: 4096
13 # applies to forge check cells, NOT to Actions runs-on: isolated — so keep
14 # `node … tsc --noEmit &&` in forge.json check.run for forge-side cells and
15 # do not duplicate typecheck here. This workflow only runs the gate half.
16 #
17 # The serial SHARDS=1 gate is ~32–50m wall clock. The isolated runner silently
18 # cuts a single job at ~30m (pod/runner wall), so timeout-minutes: 120 does
19 # not help. Split the gate across matrix jobs via --shard N/M; each cell
20 # still runs FORGE_CHECK_SHARDS=1 (one bun test at a time — do not reintroduce
21 # parallel bun in one container; that OOMed on #386). A final `check` job
22 # aggregates the matrix so the required status stays `check / check (…)`.
23 on:
24 push:
25 branches: [main]
26 pull_request:
27
28 jobs:
29 gate:
30 runs-on: isolated
31 timeout-minutes: 120
32 strategy:
33 fail-fast: false
34 matrix:
35 # 174 gate files → ~58 each; ~11–17m expected, under the ~30m runner wall.
36 shard: [1, 2, 3]
37 env:
38 REGISTRY: forgejo-http.forgejo.svc.cluster.local:3000
39 # Do not set NODE_OPTIONS job-wide: a big heap lets Node claim the
40 # cgroup and the kernel OOMs (`Killed`, exit 137). Typecheck lives in
41 # forge.json check.run (4Gi forge cells), not on this runner.
42 FORGE_CHECK_SHARD_TOTAL: "3"
43 steps:
44 - name: derive owner + app
45 run: |
46 set -eu
47 echo "OWNER=${GITHUB_REPOSITORY%%/*}" >> "$GITHUB_ENV"
48 echo "APP=${GITHUB_REPOSITORY##*/}" >> "$GITHUB_ENV"
49
50 - name: clone source at this ref
51 env:
52 TOKEN: ${{ github.token }}
53 run: |
54 set -eu
55 git clone "http://x-access-token:$TOKEN@$REGISTRY/$OWNER/$APP.git" src
56 cd src
57 # A pull from a fork: its head is in this repository only as
58 # refs/pull/<n>/head, which a clone does not fetch.
59 if ! git cat-file -e "$GITHUB_SHA^{commit}" 2>/dev/null; then
60 git fetch -q origin "+refs/pull/${{ github.event.pull_request.number }}/head"
61 fi
62 git checkout -q "$GITHUB_SHA"
63 echo "checked out $(git rev-parse --short HEAD)"
64
65 - name: runner limits (observability only)
66 run: |
67 # Print the real budget this job runs under. Never fails the job.
68 set +e
69 cg=/sys/fs/cgroup
70 rd() { [ -r "$1" ] && echo "$2=$(cat "$1")" >&2; }
71 echo "runner-limits: nproc=$(nproc) host_mem_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo)" >&2
72 rd $cg/memory.max memory.max; rd $cg/memory.high memory.high
73 rd $cg/memory.swap.max memory.swap.max; rd $cg/cpu.max cpu.max
74 rd $cg/memory/memory.limit_in_bytes v1.memory.limit
75 rd $cg/cpu/cpu.cfs_quota_us v1.cpu.quota; rd $cg/cpu/cpu.cfs_period_us v1.cpu.period
76 echo "runner-limits: bun=$(bun --version) node=$(node --version) git=$(git --version)" >&2
77 echo "runner-limits: v8_heap_limit_mb=$(node -e 'console.log(Math.round(require("v8").getHeapStatistics().heap_size_limit/2**20))')" >&2
78 true
79
80 - name: install dev dependencies
81 working-directory: src
82 run: bun install --frozen-lockfile
83
84 # Gate slice only — typecheck skipped on isolated Actions (see header).
85 # forge.json check.run still runs tsc on forge check cells (4Gi).
86 - name: gate shard ${{ matrix.shard }}/3
87 working-directory: src
88 env:
89 FORGE_CHECK_SHARD_INDEX: ${{ matrix.shard }}
90 run: |
91 set -eu
92 # Observability: a stderr heartbeat every 60s with elapsed time and
93 # cgroup memory (current/peak), a warning at 25m before the ~30m
94 # runner wall, and one CI_SUMMARY JSON line at exit. Read-only.
95 T0=$(date +%s)
96 memof() { cat /sys/fs/cgroup/$1 2>/dev/null || echo -1; }
97 ( while sleep 60; do
98 el=$(( $(date +%s) - T0 ))
99 echo "heartbeat: shard=${{ matrix.shard }}/3 elapsed_s=$el mem_current=$(memof memory.current) mem_peak=$(memof memory.peak)" >&2
100 if [ "$el" -ge 1500 ] && [ "$el" -lt 1560 ]; then
101 echo "::warning::gate shard ${{ matrix.shard }} at ${el}s — the isolated runner cuts jobs near 1800s" >&2
102 fi
103 done ) &
104 HB=$!
105 summary() {
106 rc=$?; kill "$HB" 2>/dev/null || true
107 echo "CI_SUMMARY {\"phase\":\"gate\",\"shard\":\"${{ matrix.shard }}/3\",\"event\":\"$GITHUB_EVENT_NAME\",\"exit\":$rc,\"wall_s\":$(( $(date +%s) - T0 )),\"mem_peak\":$(memof memory.peak),\"mem_max\":\"$(memof memory.max)\"}" >&2
108 }
109 trap summary EXIT
110 # A pull judges the fast tier (#1198); main's push runs the whole gate.
111 if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
112 git fetch -q origin "$GITHUB_BASE_REF"
113 echo "$ bun scripts/test-shards.ts --timeout 60000 --tier fast --diff origin/$GITHUB_BASE_REF..HEAD"
114 FORGE_CHECK_SHARDS=1 bun scripts/test-shards.ts --timeout 60000 --tier fast --diff "origin/$GITHUB_BASE_REF..HEAD" --shard ${{ matrix.shard }}/3
115 exit $?
116 fi
117 echo "$ FORGE_CHECK_SHARDS=1 bun scripts/test-shards.ts --timeout 60000 --gate --shard ${{ matrix.shard }}/3"
118 # stderr for start lines: Actions often fully-buffers piped stdout
119 # until process exit, which an OOM never reaches.
120 FORGE_CHECK_SHARDS=1 bun scripts/test-shards.ts --timeout 60000 --gate --shard ${{ matrix.shard }}/3
121
122 # Required PR status is `check / check (pull_request)`. Matrix cells report
123 # as `check / gate (N, …)`; this job restores the top-level `check` name.
124 check:
125 runs-on: isolated
126 needs: [gate]
127 if: always()
128 timeout-minutes: 10
129 steps:
130 - name: require every gate shard green
131 run: |
132 set -eu
133 echo "gate result: ${{ needs.gate.result }}"
134 test "${{ needs.gate.result }}" = "success"
.forgejo/workflows/flake-hunt.yml +2 −2
@@ -7,8 +7,8 @@ name: flake-hunt
77 # proposes that as a pull. Nobody hand-edits the list.
88 #
99 # Flakiness is judged per file across runs, so the gate splits into slices
10 # and each slice hunts on its own: the isolated runner cuts a job at ~30m
11 # (see check.yml), and five loaded runs of the whole gate is hours.
10 # and each slice hunts on its own: the isolated runner cuts a job at ~30m,
11 # and five loaded runs of the whole gate is hours.
1212 #
1313 # Needs two settings on the mirror: secret FORGE_TOKEN (a forge token with
1414 # write on the repository) and variable FORGE_URL (the forge's origin).
.forgejo/workflows/release.yml +49 −2
@@ -1,6 +1,8 @@
11 name: release
2 # A version tag ships: build the image from the Dockerfile, sign it, push the
3 # chart. Flux rolls the new chart version to prod once the platform pins it.
2 # A version tag ships: wait for the forge's own check of the tagged commit,
3 # build the image from the Dockerfile (which boots what it built), sign it,
4 # push the chart. Flux rolls the new chart version to prod once the platform
5 # pins it. This is the release's one gate (#2794); docs/ops/ROLL.md says the rest.
46 on:
57 push:
68 tags: ["v*"]
@@ -54,6 +56,51 @@ jobs:
5456 git clone --depth 1 --branch "$GITHUB_REF_NAME" \
5557 "http://x-access-token:$TOKEN@$REGISTRY/$OWNER/$APP.git" src
5658
59 # The gate of record (#2794), half one: the forge's own full check —
60 # the one every push to main runs — green on this very commit. Nothing
61 # on this runner re-runs the suite: its copy held four good releases on
62 # runner-only reds and caught none of the real failures. Half two is
63 # the build below: the Dockerfile boots the image it built
64 # (src/release/boot.ts) and fails the build if it does not answer.
65 # Reads the two settings the flake hunt already needs on the mirror:
66 # variable FORGE_URL (the forge's origin) and secret FORGE_TOKEN.
67 - name: the forge's own check is green on this commit
68 env:
69 FORGE_URL: ${{ vars.FORGE_URL }}
70 FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
71 run: |
72 set -eu
73 [ -n "${FORGE_URL:-}" ] || { echo "FATAL: variable FORGE_URL is not set on the mirror"; exit 1; }
74 URL="${FORGE_URL%/}"
75 REPO="$OWNER/$APP"
76 SHA=$(git -C src rev-parse HEAD)
77 read_check() {
78 if [ -n "${FORGE_TOKEN:-}" ]; then
79 curl -fsS -H "authorization: Bearer $FORGE_TOKEN" "$URL/api/v1/repos/$REPO/commits/$SHA/check"
80 else
81 curl -fsS "$URL/api/v1/repos/$REPO/commits/$SHA/check"
82 fi
83 }
84 # main's check takes minutes; the tag can be cut before it ends.
85 # Two hours is forge.json's check timeout. A push to main queues
86 # its check at once, so a commit still unchecked after ten minutes
87 # was never main's: that is not worth the two hours.
88 START=$(date +%s)
89 DEADLINE=$(( START + 7200 ))
90 while :; do
outdated

Only ok and failed end the loop. A commit whose check was superseded reads none for the full 2 h, and the log says only 'waiting'. Add a superseded) arm that exits 1 with an actionable message. Consider also bounding none to a short grace.

91 BODY=$(read_check || echo '{"state":"unreachable"}')
92 STATE=$(printf '%s' "$BODY" | sed -n 's/.*"state":"\([a-z]*\)".*/\1/p')
93 case "$STATE" in
94 ok) echo "$REPO@$SHA: $(printf '%s' "$BODY" | sed -n 's/.*"summary":"\([^"]*\)".*/\1/p')"; exit 0 ;;
95 failed) echo "FATAL: the forge's own check failed on $SHA: $URL/$REPO/runs/$(printf '%s' "$BODY" | sed -n 's/.*"runId":"\([^"]*\)".*/\1/p')"; exit 1 ;;
96 superseded) echo "FATAL: main's check of $SHA was superseded and will not run; tag a commit main has checked"; exit 1 ;;
97 none) [ $(( $(date +%s) - START )) -lt 600 ] || { echo "FATAL: main never checked $SHA in 10 min; tag a commit on main"; exit 1; } ;;
98 esac
99 [ "$(date +%s)" -lt "$DEADLINE" ] || { echo "FATAL: no green check of $SHA on $URL/$REPO in 2 h (last: ${STATE:-unreadable})"; exit 1; }
100 echo "waiting: the forge's check of $SHA is ${STATE:-unreadable}"
101 sleep 30
102 done
103
57104 - name: build & push image (rootless buildkit)
58105 run: |
59106 set -eu
README.md +2
@@ -1565,6 +1565,8 @@ Under the rows, once the tests have begun, the run page shows a progress bar of
15651565
15661566 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.
15671567
1568 `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").
1569
15681570 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.
15691571
15701572 ## Flags
docs/ops/ROLL.md +35
@@ -142,6 +142,41 @@ request routed to it in that beat is refused.
142142 Release as the release workflow does. Nothing in this recipe changes the
143143 workflow or the chart.
144144
145 ## The gate of record (#2794)
146
147 A release ships on two proofs, and nothing else holds it:
148
149 1. **The forge's own full check, green on the release commit.** The check
150 every push to `main` runs (`forge.json` `check.run`, never a pull's fast
151 tier), read from the forge by full commit id:
152 `GET /api/v1/repos/:owner/:name/commits/:oid/check` answers
153 `{commit, state, runId, summary, finishedAt}`, where `state` is `ok`,
154 `failed`, `pending` (queued or running), `superseded` or `none` (main
155 never checked it). The newest of main's own checks of that commit
156 decides; a cancelled one is passed over when another check of the commit
157 stands (a restart queues it again), and is `superseded` when none does:
158 main moved on while it waited, and nothing will check that commit again.
159 `release.yml`'s first gated step polls it every 30 s for up to two hours
160 and builds only on `ok`; `failed` stops the release with the run's link,
161 `superseded` stops it at once, and `none` stops it after ten minutes (a
162 push to main queues its check at once). Tag a commit main has checked. It reads the mirror's variable
163 `FORGE_URL` and secret `FORGE_TOKEN`, the two the flake hunt already
164 needs.
165 2. **The built image boots.** The Dockerfile runs `bun src/release/boot.ts`
166 on the image it just built (below); a refusal fails the build and nothing
167 is pushed.
168
169 An image in the registry has therefore passed both. The mirror runs no copy
170 of the suite on a push, a pull or a tag: its `check.yml` held 0.7.170–0.7.173
171 on reds only that runner saw and caught none of the failures that reached
172 production, so it is gone (`test/release-gate.test.ts` keeps it gone). The
173 nightly `flake-hunt.yml` and `outside-gate.yml` stay: they file issues, they
174 gate nothing.
175
176 After the roll, a version not ready in `FORGE_READY_GRACE_S` is rolled back
177 to the last good one by the launcher, with no one asked (below). Until the
178 forge deploys itself (#1432), this is how a release ships.
179
145180 ## A release that does not boot (#2652)
146181
147182 Three guards stand between a release that cannot start and production, all
src/actions/runs.ts +15
@@ -8,6 +8,7 @@
88 * the system's own voice and takes no actor.
99 */
1010
11 import { commitCheck, isCommitId, type CommitCheck } from "../checks/commit_check.ts";
1112 import { mayViewId } from "../core/sealed.ts";
1213 import { requireVerified } from "./verify.ts";
1314 import { requireAbility } from "./roles.ts";
@@ -118,6 +119,20 @@ export function listRepoRuns(
118119 );
119120 }
120121
122 /**
123 * The default branch's own check of one commit (`checks/commit_check.ts`),
124 * for whoever may read the repository's runs: what a release waits on.
125 */
126 export function checkOfCommit(
127 app: App,
128 actor: User | undefined,
129 repo: Repo,
130 oid: string,
131 ): Result<CommitCheck, "not_found"> {
132 if (!isCommitId(oid) || !mayReadRuns(app, actor, repo)) return err("not_found");
133 return ok(commitCheck(app.db, repo.id, repo.defaultBranch, oid));
134 }
135
121136 /** A run by id, only if it belongs to a repository the actor can see. */
122137 export function getRepoRun(
123138 app: App,
src/checks/commit_check.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The default branch's own check of one commit: half of a release's gate of
3 * record (#2794). A release ships when the forge's full check — the
4 * `check.run` that every push to the default branch runs, never a pull's
5 * fast tier — passed on the very commit the release builds; the other half
6 * is the image proving it boots (`release/boot.ts`, in the Dockerfile).
7 *
8 * Plain data: the newest check of `oid` pushed to `branch`, read from the
9 * newest {@link WINDOW} runs of the repository by the `run_repo` index, so
10 * the answer costs the same however many runs a repository has.
11 *
12 * A cancelled check is passed over when another check of the commit stands
13 * beside it: a restart cancels a running check and queues it again in one
14 * transaction (`requeueCheck`), so the newer attempt answers. When every
15 * check of the commit was cancelled, nothing will check it again — main
16 * moved on while it waited (`superseded by <sha>`, `checks/service.ts`) or
17 * a person stopped it — and the answer is `superseded`, never `none`, so a
18 * release on that commit stops at once rather than waiting out its budget.
19 */
20
21 import type { Database } from "bun:sqlite";
22
23 /** How many of a repository's newest runs are looked through. */
24 export const WINDOW = 5000;
25
26 export type CommitCheckState = "ok" | "failed" | "pending" | "superseded" | "none";
27
28 export interface CommitCheck {
29 readonly commit: string;
30 /**
31 * `ok` passed, `failed` failed, `pending` queued or running, `superseded`
32 * every check of it was cancelled and none will follow, `none` never checked.
33 */
34 readonly state: CommitCheckState;
35 readonly runId: string | null;
36 readonly summary: string;
37 readonly finishedAt: string | null;
38 }
39
40 /** A full commit id: what a release names, and the only form asked about. */
41 export const isCommitId = (oid: string): boolean => /^[0-9a-f]{40}$/.test(oid);
42
43 export function commitCheck(
44 db: Database,
45 repoId: string,
46 branch: string,
47 oid: string,
48 ): CommitCheck {
outdated

Skipping every cancelled row is right for a restart (the run is recreated as a newer attempt) but wrong for a supersede: service.ts cancels a queued main check when a newer main commit lands, and nothing recreates it. Then this answers none forever. Return superseded when the newest row for the commit is cancelled and nothing newer exists for it.

49 const row = db
50 .query<
51 { id: string; state: string; summary: string; finished_at: string | null },
52 [string, string, string, number]
53 >(
54 `SELECT id, state, summary, finished_at
55 FROM (SELECT id, kind, issue_id, env, commit_oid, state, summary, finished_at
56 FROM run WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?4)
57 WHERE kind = 'check' AND issue_id IS NULL AND env = ?2
58 AND commit_oid = ?3
59 ORDER BY state <> 'cancelled' DESC, id DESC LIMIT 1`,
60 )
61 .get(repoId, branch, oid, WINDOW);
62 if (row === null) return { commit: oid, state: "none", runId: null, summary: "", finishedAt: null };
63 const state: CommitCheckState =
64 row.state === "ok" || row.state === "failed"
65 ? row.state
66 : row.state === "cancelled"
67 ? "superseded"
68 : "pending";
69 return { commit: oid, state, runId: row.id, summary: row.summary, finishedAt: row.finished_at };
70 }
src/http/api.ts +11
@@ -280,6 +280,7 @@ import {
280280 } from "../actions/migrate.ts";
281281 import {
282282 cancelRepoRun,
283 checkOfCommit,
283284 getRepoRun,
284285 listRepoRuns,
285286 runLog,
@@ -2583,6 +2584,16 @@ export function apiRoutes(router: Router<Handler>): void {
25832584 });
25842585 });
25852586
2587 // The default branch's own check of a full commit id: what a release
2588 // waits on before it builds (#2794). Never cached: `pending` turns.
2589 router.add("GET", "/api/v1/repos/:owner/:name/commits/:oid/check", (ctx) => {
2590 const repo = loadRepo(ctx);
2591 if (!repo) return notFound();
2592 const found = checkOfCommit(ctx.app, ctx.auth.user, repo, ctx.params.oid ?? "");
2593 if (!found.ok) return notFound();
2594 return json(found.value, 200, { "cache-control": "no-store" });
2595 });
2596
25862597 // The path is optional: no path lists the root tree; a directory lists its
25872598 // entries; a file returns its content (utf-8, or base64 for binary).
25882599 const contentsHandler = async (ctx: Ctx): Promise<Response> => {
test/commit-check.test.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * The default branch's own check of one commit, what a release waits on
3 * (#2794): only main's own check counts (never a pull's fast tier, never
4 * another branch), the newest decides, a cancelled check says nothing, and
5 * the API answers it by full commit id to whoever may read the runs.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import type { Database } from "bun:sqlite";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { commitCheck } from "../src/checks/commit_check.ts";
15 import { migrations } from "../src/core/schema.ts";
16 import { cancelRun, claimRun, createRun, finishRun, requeueCheck } from "../src/core/runs.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
19 import { openDb } from "../src/kernel/db.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const OID = (c: string) => c.repeat(40);
25
26 function check(db: Database, repoId: string, commit: string, opts: { env?: string; issueId?: string } = {}) {
27 return createRun(db, {
28 repoId,
29 kind: "check",
30 env: opts.env ?? "main",
31 commitOid: OID(commit),
32 reason: "push",
33 ...(opts.issueId === undefined ? {} : { issueId: opts.issueId }),
34 });
35 }
36 function finish(db: Database, id: string, state: "ok" | "failed") {
37 claimRun(db, id);
38 finishRun(db, id, { state, summary: state === "ok" ? "check passed in 368s" : "check failed: 1 test failed" });
39 }
40
41 describe("commitCheck", () => {
42 let db: Database;
43 beforeAll(() => {
44 db = openDb(":memory:", migrations);
45 db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'trevato', 't@x', '')");
46 db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'forge')");
47 db.run(
48 "INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('i1', 'r1', 7, 'pull', 'p', 'u1')",
49 );
50 });
51
52 test("a commit main never checked is none", () => {
53 expect(commitCheck(db, "r1", "main", OID("a"))).toEqual({
54 commit: OID("a"),
55 state: "none",
56 runId: null,
57 summary: "",
58 finishedAt: null,
59 });
60 });
61
62 test("a pull's check and another branch's check of the same commit do not count", () => {
63 finish(db, check(db, "r1", "b", { env: "pull-7", issueId: "i1" }).id, "ok");
64 finish(db, check(db, "r1", "b", { env: "next" }).id, "ok");
65 expect(commitCheck(db, "r1", "main", OID("b")).state).toBe("none");
66 });
67
68 test("queued and running are pending; a finished check is its verdict", () => {
69 const run = check(db, "r1", "c");
70 expect(commitCheck(db, "r1", "main", OID("c")).state).toBe("pending");
71 claimRun(db, run.id);
72 expect(commitCheck(db, "r1", "main", OID("c")).state).toBe("pending");
73 finishRun(db, run.id, { state: "ok", summary: "check passed in 368s" });
74 expect(commitCheck(db, "r1", "main", OID("c"))).toMatchObject({
75 state: "ok",
76 runId: run.id,
77 summary: "check passed in 368s",
78 });
79 });
80
81 test("the newest check decides, and a cancelled one is passed over", () => {
82 finish(db, check(db, "r1", "d").id, "failed");
83 expect(commitCheck(db, "r1", "main", OID("d")).state).toBe("failed");
84 const green = check(db, "r1", "d");
85 finish(db, green.id, "ok");
86 expect(commitCheck(db, "r1", "main", OID("d")).runId).toBe(green.id);
87 cancelRun(db, check(db, "r1", "d").id);
88 expect(commitCheck(db, "r1", "main", OID("d"))).toMatchObject({ state: "ok", runId: green.id });
89 });
90
91 test("a check main moved past before it ran is superseded, not none", () => {
92 const waiting = check(db, "r1", "g");
93 // What `checks/service.ts` does when a newer commit lands on main.
94 cancelRun(db, waiting.id, `superseded by ${OID("h").slice(0, 7)}`);
95 check(db, "r1", "h");
96 expect(commitCheck(db, "r1", "main", OID("g"))).toMatchObject({
97 state: "superseded",
98 runId: waiting.id,
99 summary: "superseded by hhhhhhh",
100 });
101 });
102
103 test("a check a restart queued again is pending, not superseded", () => {
104 const running = check(db, "r1", "k");
105 claimRun(db, running.id);
106 const again = requeueCheck(db, running.id);
107 expect(again).toBeDefined();
108 expect(commitCheck(db, "r1", "main", OID("k"))).toMatchObject({ state: "pending", runId: again!.again.id });
109 });
110 });
111
112 describe("GET /api/v1/repos/:owner/:name/commits/:oid/check", () => {
113 let dataDir: string;
114 let app: App;
115 let http: ForgeHttp;
116
117 const get = async (path: string) => {
118 const res = await http.handle(new Request(`http://forge.test${path}`));
119 return { status: res.status, cache: res.headers.get("cache-control"), body: await res.text() };
120 };
121
122 beforeAll(async () => {
123 dataDir = await mkdtemp(join(tmpdir(), "forge-commit-check-"));
124 app = createApp({ dataDir, port: 0 });
125 http = forgeHttp(app);
126 const made = await createUser(app.db, {
127 origin: "seed",
128 trusted: true,
129 username: "alice",
130 email: "[email protected]",
131 password: "a-good-passphrase",
132 adminIfFirst: true,
133 });
134 if (!made.ok) throw new Error(String(made.error));
135 for (const [name, visibility] of [["forge", "public"], ["hidden", "private"]] as const) {
136 const repo = await createRepo(app, made.value, { name, visibility });
137 if (!repo.ok) throw new Error(String(repo.error));
138 await commitToBranch(app, made.value, repo.value, {
139 message: "Start",
140 files: [{ path: "README.md", content: `# ${name}\n` }],
141 });
142 finish(app.db, check(app.db, repo.value.id, "e").id, "ok");
143 }
144 }, 60_000);
145
146 afterAll(async () => {
147 app?.close();
148 await rm(dataDir, { recursive: true, force: true });
149 });
150
151 test("answers main's own check of a full commit id, uncached", async () => {
152 const res = await get(`/api/v1/repos/alice/forge/commits/${OID("e")}/check`);
153 expect(res.status).toBe(200);
154 expect(res.cache).toBe("no-store");
155 expect(JSON.parse(res.body)).toMatchObject({ commit: OID("e"), state: "ok", summary: "check passed in 368s" });
156 const none = await get(`/api/v1/repos/alice/forge/commits/${OID("f")}/check`);
157 expect(JSON.parse(none.body)).toMatchObject({ state: "none", runId: null });
158 });
159
160 test("a short or malformed id, and a repository the caller cannot read, are 404", async () => {
161 expect((await get(`/api/v1/repos/alice/forge/commits/eeeeeee/check`)).status).toBe(404);
162 expect((await get(`/api/v1/repos/alice/forge/commits/${"E".repeat(40)}/check`)).status).toBe(404);
163 expect((await get(`/api/v1/repos/alice/hidden/commits/${OID("e")}/check`)).status).toBe(404);
164 });
165 });
test/release-gate.test.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * One release gate (#2794): the forge's own full check green on the
3 * release commit, then the built image booting. The release workflow waits
4 * on the first before it builds, the Dockerfile's build is the second, and
5 * no workflow on the mirror runs its own copy of the suite on a push, a
6 * pull or a tag — that copy held good releases on runner-only reds.
7 */
8 import { describe, expect, test } from "bun:test";
9 import { readdirSync, readFileSync } from "node:fs";
10 import { join } from "node:path";
11
12 const ROOT = join(import.meta.dir, "..");
13 const WORKFLOWS = join(ROOT, ".forgejo", "workflows");
14 const read = (path: string) => readFileSync(path, "utf8");
15
16 /** The workflow's `on:` block: the lines up to `jobs:`. */
17 const triggers = (yml: string) => yml.slice(yml.indexOf("\non:"), yml.indexOf("\njobs:"));
18
19 describe("the release gate", () => {
20 const release = read(join(WORKFLOWS, "release.yml"));
21
22 test("the release waits on the forge's own check of the commit before it builds", () => {
23 const wait = release.indexOf("- name: the forge's own check is green on this commit");
24 const build = release.indexOf("- name: build & push image");
25 expect(wait).toBeGreaterThan(0);
26 expect(build).toBeGreaterThan(wait);
27 const step = release.slice(wait, build);
28 expect(step).toContain("/api/v1/repos/$REPO/commits/$SHA/check");
29 expect(step).toContain("SHA=$(git -C src rev-parse HEAD)");
30 // Only `ok` lets the build start; `failed`, `superseded` (nothing will
31 // check that commit again) and `none` past its grace stop it at once.
32 expect(step).toMatch(/ok\) [^\n]*exit 0/);
33 expect(step).toMatch(/failed\) [^\n]*exit 1/);
34 expect(step).toMatch(/superseded\) [^\n]*exit 1/);
35 expect(step).toMatch(/none\) [^\n]*-lt 600 [^\n]*exit 1/);
36 });
37
38 test("the build boots the image it built", () => {
39 expect(read(join(ROOT, "Dockerfile"))).toContain("RUN bun src/release/boot.ts");
40 });
41
42 test("no workflow run by a push, a pull or a tag runs the suite", () => {
43 const suite = /test-shards\.ts|\bbun test\b/;
44 const onEvent = /^\s+(push|pull_request|pull_request_target):/m;
45 const copies = readdirSync(WORKFLOWS)
46 .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
47 .filter((name) => {
48 const yml = read(join(WORKFLOWS, name));
49 return onEvent.test(triggers(yml)) && suite.test(yml);
50 });
51 expect(copies).toEqual([]);
52 });
53 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs