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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2794-one-release-gate-the-forge-s-own-full-ch → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 7 | 7 | # proposes that as a pull. Nobody hand-edits the list. |
| 8 | 8 | # |
| 9 | 9 | # 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. | |
| 12 | 12 | # |
| 13 | 13 | # Needs two settings on the mirror: secret FORGE_TOKEN (a forge token with |
| 14 | 14 | # write on the repository) and variable FORGE_URL (the forge's origin). |
.forgejo/workflows/release.yml +49 −2
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | 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. | |
| 4 | 6 | on: |
| 5 | 7 | push: |
| 6 | 8 | tags: ["v*"] |
| @@ -54,6 +56,51 @@ jobs: | ||
| 54 | 56 | git clone --depth 1 --branch "$GITHUB_REF_NAME" \ |
| 55 | 57 | "http://x-access-token:$TOKEN@$REGISTRY/$OWNER/$APP.git" src |
| 56 | 58 | |
| 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 | |
| 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 | ||
| 57 | 104 | - name: build & push image (rootless buildkit) |
| 58 | 105 | run: | |
| 59 | 106 | 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 | ||
| 1565 | 1565 | |
| 1566 | 1566 | 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. |
| 1567 | 1567 | |
| 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 | ||
| 1568 | 1570 | 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. |
| 1569 | 1571 | |
| 1570 | 1572 | ## Flags |
docs/ops/ROLL.md +35
| @@ -142,6 +142,41 @@ request routed to it in that beat is refused. | ||
| 142 | 142 | Release as the release workflow does. Nothing in this recipe changes the |
| 143 | 143 | workflow or the chart. |
| 144 | 144 | |
| 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 | ||
| 145 | 180 | ## A release that does not boot (#2652) |
| 146 | 181 | |
| 147 | 182 | Three guards stand between a release that cannot start and production, all |
src/actions/runs.ts +15
| @@ -8,6 +8,7 @@ | ||
| 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 | 12 | import { mayViewId } from "../core/sealed.ts"; |
| 12 | 13 | import { requireVerified } from "./verify.ts"; |
| 13 | 14 | import { requireAbility } from "./roles.ts"; |
| @@ -118,6 +119,20 @@ export function listRepoRuns( | ||
| 118 | 119 | ); |
| 119 | 120 | } |
| 120 | 121 | |
| 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 | ||
| 121 | 136 | /** A run by id, only if it belongs to a repository the actor can see. */ |
| 122 | 137 | export function getRepoRun( |
| 123 | 138 | 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 |
||
| 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 { | ||
| 280 | 280 | } from "../actions/migrate.ts"; |
| 281 | 281 | import { |
| 282 | 282 | cancelRepoRun, |
| 283 | checkOfCommit, | |
| 283 | 284 | getRepoRun, |
| 284 | 285 | listRepoRuns, |
| 285 | 286 | runLog, |
| @@ -2583,6 +2584,16 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2583 | 2584 | }); |
| 2584 | 2585 | }); |
| 2585 | 2586 | |
| 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 | ||
| 2586 | 2597 | // The path is optional: no path lists the root tree; a directory lists its |
| 2587 | 2598 | // entries; a file returns its content (utf-8, or base64 for binary). |
| 2588 | 2599 | 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 | }); |
new above
offline
Only
okandfailedend the loop. A commit whose check was superseded readsnonefor the full 2 h, and the log says only 'waiting'. Add asuperseded)arm that exits 1 with an actionable message. Consider also boundingnoneto a short grace.