Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc097f36c5 | ||
|
|
56d8e8a3d5 | ||
|
|
4589659bc1 | ||
|
|
357a636a3a | ||
|
|
9e205e8201 | ||
|
|
73a313301b | ||
|
|
eddf718a5c | ||
|
|
c83a3cd3e2 | ||
|
|
bf8c6f4a89 | ||
|
|
bdf8932328 | ||
|
|
16d11cd6cd | ||
|
|
8ce77fcb0d | ||
|
|
cdd5568adb | ||
|
|
ff4b45b025 | ||
|
|
f47cf45b0c | ||
|
|
e7c9160c7b | ||
|
|
2d58779675 | ||
|
|
cbbe3e1ce2 | ||
|
|
9a7ab73952 | ||
|
|
2b85afe4ea | ||
|
|
eabe04bc5e | ||
|
|
36018be8d1 | ||
|
|
8c496993e4 | ||
|
|
e85a088f85 | ||
|
|
8a795df0ce | ||
|
|
f5a0566198 | ||
|
|
1e7a0701fd | ||
|
|
43f69bf167 | ||
|
|
f9435c2a03 | ||
|
|
3b191b6b34 | ||
|
|
85bdbe3383 | ||
|
|
ae4baf145d | ||
|
|
4512312b9f | ||
|
|
e233f196b5 | ||
|
|
3477e933df | ||
|
|
d0ad6e942b | ||
|
|
7f686aaf6d | ||
|
|
f13222b76b | ||
|
|
d1e7ba19ca | ||
|
|
0ffdcf14bd | ||
|
|
be10bdc828 | ||
|
|
2201c30284 | ||
|
|
75dfe2fa75 | ||
|
|
409bf23e6a | ||
|
|
31c3191305 | ||
|
|
6a067174ed |
@@ -8,7 +8,6 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||
store-dir=${HOME}/.local/share/pnpm/store
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
|
||||
@@ -201,21 +201,8 @@ git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||
pnpm install
|
||||
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||
# trust anchor outside worktree authority.
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
@@ -243,7 +230,6 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm preflight # Checkout/dependency/generated-state validation
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -23,3 +23,19 @@ Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ th
|
||||
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||
|
||||
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
+44
-20
@@ -1,6 +1,6 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||
**Phase:** EXECUTING — RM-03 **GATE-READY**, merge-gate assigned, HELD for Jason. RM-02 CI in flight.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
|
||||
## Head
|
||||
@@ -16,13 +16,49 @@
|
||||
|
||||
## In-flight
|
||||
|
||||
| Task | Owner | State |
|
||||
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||
| Task | Owner | State |
|
||||
| ------------------------------ | ------------- | ------------------------------------------------------------------------------------ |
|
||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (#1027) |
|
||||
| RM-03 queue guard | merge-gate | **GATE-READY** @ `78ec47cd` — rev-974 APPROVED (id 66) + CI 9/9 JSON; HELD for Jason |
|
||||
| RM-02 gate registry ★keystone | f10-coder | masking blocker fixed @ `f9746b23`; CI #2187 running; **not** gate-ready |
|
||||
| RM-61 terminal-green exemption | unassigned | ruled B; negative control FIRST, then adopt — or fall to A |
|
||||
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
|
||||
| #1023 queue-guard attempt | Jason | SUPERSEDED-PENDING-JASON — RM-03 supersedes it |
|
||||
|
||||
**RM-03 gate-ready evidence** (each element observed, not relayed): head triple all equal at
|
||||
`78ec47cd97bf0abc49334401f08c5b86e5be3a1a`; latest review **id 66 APPROVED at the current head** (id 64
|
||||
`REQUEST_CHANGES` correctly superseded); `Closes #1019`; CI #2186 **9/9 JSON child-step scan**, confirmed
|
||||
independently by two observers whose counts **agreed**. Reviewer attacked rather than read — real-failure
|
||||
payload → exit 3 `ASSERTED_NOT_READY`, bypass re-attack rejected, 170 KiB payload rc0, both tri-state arms.
|
||||
|
||||
> ⚠ **The queue guard is still inert for this merge** — RM-03 _is_ the fix and is not merged. The gate
|
||||
> records `queue-guard: ZERO-INFORMATION (inert, D-23, owner RM-03)`.
|
||||
> **The last merge to pass through the broken gate is the one that fixes it.** After #1032 lands, that
|
||||
> field becomes real evidence for the first time since it was written.
|
||||
|
||||
## Delivery gates — DOCTRINE CHANGE 2026-08-01
|
||||
|
||||
**The gate definition was incomplete from mission setup: the merge-gate verdict step was missing.**
|
||||
Root cause (**D-26**): the gates were **restated from memory** into `MISSION.md`/`KICKSTART.md` instead of
|
||||
**referenced**, and the omission propagated into every worker brief issued since. It then recurred _inside
|
||||
the correction_ — which dropped five details including a security precondition and cited a file that does
|
||||
not exist.
|
||||
|
||||
**Fix is render-not-restate, mechanically.** `MISSION.md` and `KICKSTART.md` now **reference**
|
||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md` and `~/.config/mosaic/fleet/roles/validator.md` and
|
||||
state only the gate **order**:
|
||||
|
||||
> independent review (author ≠ reviewer) → remediation → **CI terminal-green at the exact head, full step
|
||||
> scan** → **merge-gate verdict `GO`/`NO-GO`/`HOLD`** (commit-bound; **VOID on head move**; posted durably
|
||||
> with enumerated evidence under its own minted identity) → **coordinator head-pinned merge**
|
||||
|
||||
- **After a `GO`, pushes freeze** — a doc tweak voids the verdict. Gate-ready is a **freeze point**.
|
||||
- The coordinator assigns the gate seat; the orchestrator owns getting a PR _gate-ready_.
|
||||
- **Queue guard is ZERO-INFORMATION until RM-03 lands** (D-23) — record it as
|
||||
`queue-guard: ZERO-INFORMATION (inert, D-23, owner RM-03)`. _A mandated field must not become a
|
||||
manufactured one._
|
||||
- Gate seat identity: `gitea-mosaicstack-merge-gate` minted least-privilege, verified `push=False` —
|
||||
it structurally cannot merge.
|
||||
|
||||
## Fleet seats
|
||||
|
||||
@@ -67,18 +103,6 @@
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
|
||||
## Decisions log
|
||||
|
||||
@@ -25,8 +25,18 @@ mechanically until Build 3 (rotation) makes it automatic.
|
||||
## Standing invariants (never violate)
|
||||
|
||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||
- **Delivery gates — REFERENCE the canonical files, never restate them:**
|
||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
|
||||
`~/.config/mosaic/fleet/roles/validator.md` (validator role). Order:
|
||||
independent review (author ≠ reviewer, `rev-974`; pre-registered diff-blind checks committed before the
|
||||
diff is read) → remediation → **CI terminal-green at the exact head by full step scan** →
|
||||
**merge-gate verdict `GO`/`NO-GO`/`HOLD`**, commit-bound and **void the instant the head moves**, posted
|
||||
durably with enumerated evidence under its own minted identity → **coordinator head-pinned merge**.
|
||||
The queue guard runs but is **zero-information until RM-03 lands** (D-23) and must not be cited as evidence.
|
||||
**After a `GO`, freeze pushes** — even a doc tweak voids the verdict. The coordinator assigns the gate seat.
|
||||
- **Query for refutation, never for confirmation.** A subordinate asked to confirm a hypothesis will
|
||||
agree — the bias is in the question, not the answerer, and agent seats are agreeable by construction.
|
||||
State the hypothesis as yours, ask for the evidence that KILLS it, and reproduce when it matters.
|
||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||
|
||||
@@ -91,6 +91,54 @@ gate/program; the LLM handles only genuine judgment.
|
||||
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
|
||||
> choke-point executor and spine verify from _outside_ the worktree's authority.
|
||||
|
||||
### First-class principle — query for refutation, never for confirmation
|
||||
|
||||
> **A subordinate asked to confirm a hypothesis will agree. Ask it to refute, with evidence.**
|
||||
>
|
||||
> The bias is induced by the **query**, not by the answerer's diligence. _"The DB flaked — please
|
||||
> confirm"_ and _"confirm or refute this, with the log line that proves it"_ are different instruments,
|
||||
> and they return different answers to the same question. The first harvests agreement; only the second
|
||||
> can return **"you are wrong, and here is why."**
|
||||
>
|
||||
> This matters most with agent subordinates, which are **agreeable by construction**: fluent, eager to
|
||||
> be useful, and structurally disinclined to tell the dispatcher their premise is false. A confirmation
|
||||
> query aimed at one is close to a guaranteed yes — so the discipline cannot rest on the answerer being
|
||||
> rigorous. **It has to be built into how the question is asked.**
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01). Origin: the orchestrator hypothesised that a coincident
|
||||
> `ci-postgres` failure caused a CI test failure and asked the implementing seat to **confirm or refute**
|
||||
> it. The seat **refuted it** with the log (`ci-postgres:5432 - accepting connections`, migrations
|
||||
> completed) and identified the real cause. Reproduction on an identical head then settled it. Had the
|
||||
> query been phrased for confirmation, the agreement would have been returned, **D-21 would have been
|
||||
> re-classified on a false premise**, and a banked finding would have been silently corrupted.
|
||||
>
|
||||
> **Operationally:** state your hypothesis explicitly, mark it as yours, ask for _evidence that kills
|
||||
> it_, and say what evidence would change your mind. A hypothesis you cannot describe how to falsify is
|
||||
> not yet a hypothesis. Where the answer is consequential, **reproduce** rather than accept — two
|
||||
> independent runs beat one confident report.
|
||||
|
||||
### First-class principle — redundant observation on evidence-bearing steps
|
||||
|
||||
> **Two observers of the same evidence, disagreeing, catch what neither catches alone.** Apply redundancy
|
||||
> not only to judgement calls but to **evidence gathering itself** — the step everyone assumes is
|
||||
> mechanical and therefore skips.
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) from **D-33**. A seat scanned a pipeline with `-f json` and reported 9
|
||||
> steps; the orchestrator scanned the same pipeline in the wrapper's default text mode and reported 8.
|
||||
> **The default output omits `clone`.** Every "full step scan" that night had been 8-of-9 and was stated
|
||||
> as complete in good faith. No verdict changed — but the _method_ was wrong, invisibly, and **only the
|
||||
> disagreement between two counts surfaced it.**
|
||||
>
|
||||
> The reason it survived: **a summary that resembles an enumeration is more dangerous than one that
|
||||
> obviously summarises.** A labelled list of named steps with states _looks_ like the artifact, so nobody
|
||||
> checks it against the record. Compare D-24 — `mergeable` was a _true answer to a different question_;
|
||||
> this was a _true answer to a smaller one_. Neither is a lie; both pass every sniff test.
|
||||
>
|
||||
> **Operationally:** where a step _produces evidence a decision rests on_, have it produced twice by
|
||||
> different means, and treat **any divergence as a finding rather than as noise to reconcile**. Prefer the
|
||||
> machine-readable record over the human-readable rendering — _read the artifact, not the summary_ — and
|
||||
> state the counts observed so a divergence is detectable at all.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
@@ -109,6 +157,31 @@ gate/program; the LLM handles only genuine judgment.
|
||||
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||
|
||||
## Why Builds 1–2 are necessary — two independent impossibility proofs
|
||||
|
||||
**The choke-point executor and PG spine are not a design preference. They are forced.** Twice during
|
||||
the mission's own first deliveries, work stopped against a security property that **cannot exist** at
|
||||
the layer that needed it — and both times the only resolution was an authority _outside_ the audited
|
||||
party's control, which is precisely what Builds 1–2 provide.
|
||||
|
||||
| | the audited party controls… | so what fails | found as |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- |
|
||||
| **Artifact integrity** | the manifest that certifies its own generated tree (same-UID write ⇒ regenerate manifest _and_ marker consistently, CWE-345) | tamper-evidence | **D-19** |
|
||||
| **Execution integrity** | the code that enters the sandbox (PR-controlled config executes _before_ the boundary exists) | isolation | **D-25** |
|
||||
|
||||
Both reduce to one sentence:
|
||||
|
||||
> **Self-verification by the audited party is not verification.**
|
||||
|
||||
And to one consequence: **the anchor must live outside the audited party's authority.** A local check
|
||||
cannot defend against an actor who can rewrite the check; a repo cannot grant a capability to
|
||||
PR-controlled config and simultaneously prevent that config from using it.
|
||||
|
||||
**An architecture forced by two independent impossibility proofs is stronger evidence than one argued
|
||||
for.** Neither proof was sought — both arrived while trying to ship something else, from different
|
||||
directions (a symlink manifest; a CI sandbox), at different layers. RM-59 and RM-60 are the two tracked
|
||||
dependencies this creates, and they are the same dependency in different clothes.
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||
@@ -161,6 +234,29 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
|
||||
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
|
||||
(verdict authority) and
|
||||
[`~/.config/mosaic/fleet/roles/validator.md`](file:///home/hermes/.config/mosaic/fleet/roles/validator.md)
|
||||
(validator/certificate role). **Read them; do not paraphrase them.** Restating an authoritative source
|
||||
is lossy every time — see D-26, where a subset restated from memory dropped a security precondition.
|
||||
|
||||
**Gate order** (the sequence only; the definitions are in the files above):
|
||||
1. Independent review, **author ≠ reviewer** (`rev-974` on mosaicstack), with PRE-REGISTERED diff-blind
|
||||
acceptance checks committed before the diff is read
|
||||
2. Remediation of findings
|
||||
3. **CI terminal-green** at the exact full-40 head, by **full step scan**
|
||||
4. **Merge-gate verdict — `GO` / `NO-GO` / `HOLD`** (class `merge-gate`; "Ultron" is an _instance name_,
|
||||
display data, never an authority source). **Bound to a commit and VOID the instant the head moves.**
|
||||
`HOLD` persists until replaced; a `NO-GO` answered by an empty commit must be re-issued as `NO-GO`.
|
||||
Posted durably on the PR under the gate's own minted identity, **enumerating** its evidence — a bare
|
||||
"GO — gates verified" is non-conforming.
|
||||
5. **Coordinator merge**, head-pinned. The gate never merges; it holds `push=False` by design.
|
||||
|
||||
⚠ **The CI queue guard runs but is ZERO-INFORMATION until RM-03 lands** (D-23: it returns pass for every
|
||||
possible input). It must not be cited as evidence by any gate, including the coordinator's own merge path.
|
||||
|
||||
**Assignment:** the coordinator assigns the merge-gate seat; the orchestrator does not. The orchestrator
|
||||
owns getting a PR _gate-ready_.
|
||||
|
||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||
|
||||
+737
-12
@@ -93,6 +93,729 @@ and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
|
||||
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
||||
is the class confirmed, not anecdote.
|
||||
|
||||
### RM02-REQ-10 — requirement revision, formally RECORDED AND ACCEPTED
|
||||
|
||||
Recorded under **RM-02's own clause 4** (meaning-changes retain original text, restatement, and reason).
|
||||
RM-02's specification is subject to RM-02's rules; the recursion is deliberate. `rev-974` made the PR
|
||||
**not review-ready until the coordinator records and accepts this** — discharged here.
|
||||
|
||||
**ORIGINAL (as dispatched):**
|
||||
|
||||
> _"PER-MERGE-COMMIT ASSERTION: assert that every merged commit passed every required gate, evaluated
|
||||
> AGAINST THAT COMMIT'S OWN TREE — not against current main."_
|
||||
|
||||
**RESTATEMENT (accepted 2026-08-01):**
|
||||
|
||||
> PR CI performs **unprivileged, fail-closed, current-tree** verification only. **Isolated per-commit
|
||||
> replay is deferred** to a protected post-merge/main authority, where it constitutes **detection with a
|
||||
> defined quarantine/revert response — explicitly NOT a pre-merge gate.** Inability to establish the
|
||||
> sandbox is a hard non-zero, never a skip.
|
||||
|
||||
**REASON:** isolated replay on PR CI would require granting namespace capability to PR-controlled
|
||||
config, which the same PR could then use directly — a trust boundary that does not exist at the repo
|
||||
layer (D-25). Not deferred for cost or effort; deferred because it is **impossible** here.
|
||||
|
||||
**Unblocked by: RM-60.** Accepted by `mos-remediation`; independently ruled by `rev-974`.
|
||||
|
||||
### RM-60 — why option B, and why option A does not work even alone (Mos, 2026-08-01)
|
||||
|
||||
Recorded because the reasoning is easy to get backwards, and getting it backwards buys standing kernel
|
||||
exposure for nothing.
|
||||
|
||||
- **A (runner enables unprivileged user namespaces / rootless sandbox) DOES NOT FIX THE VULNERABILITY.**
|
||||
It makes a sandbox _possible_; it says nothing about **when** the boundary is established relative to
|
||||
PR-controlled code. **The defect is an ORDERING defect** — PR config executes before the boundary
|
||||
exists. A grants a capability and leaves the ordering untouched, so B would still be required to close
|
||||
it. **A without B is kernel exposure bought for nothing.**
|
||||
- **B (protected default-branch pipeline config, or an immutable trusted launcher) IS the missing
|
||||
thing.** A launcher established from **protected** configuration, outside the PR author's control,
|
||||
entering isolation _before_ any PR-controlled executable is evaluated — that is the pre-execution
|
||||
trust boundary, and it is the same "authority outside the audited party" that **D-19 and D-25 both
|
||||
prove mandatory**. It may not need A at all: a protected launcher can use whatever isolation the
|
||||
runner already supports.
|
||||
- **B generalises; A does not.** The protected-launcher pattern is what **RM-59** needs, and is a small
|
||||
instance of the **choke-point executor** the spine needs. A buys one narrow capability at a standing
|
||||
kernel-exposure cost.
|
||||
|
||||
**Therefore B is not the cautious choice — it is the correct primitive.** A is a workaround that does
|
||||
not work on its own.
|
||||
|
||||
**Status:** on Jason's board for a morning decision, with this reasoning, alongside the queue-guard item
|
||||
(D-23), cross-referenced to RM-59. **Deliberately not actioned overnight:** enabling runner namespace
|
||||
exposure or changing provider protected-pipeline posture is a host security-posture change belonging to
|
||||
infrastructure authority. Nothing is blocked meanwhile — RM-02's head is unprivileged and fail-closed,
|
||||
the privileged experiment stays uncommitted and out of branch history.
|
||||
|
||||
### RM-03 — CANNOT_ASSERT semantics: RULED (option B), 2026-08-01
|
||||
|
||||
Recorded because it resolves an **ambiguity in the orchestrator's own brief**, and because the reasoning
|
||||
generalises beyond the queue guard.
|
||||
|
||||
The brief required that `CANNOT_ASSERT` "must NOT silently pass and must NOT permanently block" —
|
||||
**without distinguishing push from merge.** Those need different answers. `coder-mos1` stopped and asked
|
||||
rather than inferring authorisation from an ambiguous instruction plus a "carry on"; a Codex security
|
||||
review independently flagged **CWE-693** on the same point.
|
||||
|
||||
**Ruling — B: push degrades audited/exit 0; merge returns a distinct non-zero/HOLD until the provider
|
||||
recovers.** The asymmetry is the substance, not a compromise:
|
||||
|
||||
| action | consequence | posture |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- |
|
||||
| **merge** | proceeding without exact-head CI evidence is **D-23's condition in a narrower costume** — the guard passing exactly when its answer matters most | **fail CLOSED** |
|
||||
| **push** | blocking during a provider outage **bricks delivery** — the Pi-brick class (P-RECOVERY-001), a gate whose own unavailability prevents recovery from it | **degrade, AUDITED** |
|
||||
|
||||
**"Must not permanently block" is satisfied:** a _temporary_ block pending evidence is not a _permanent_
|
||||
one. Merge HOLD clears on provider recovery, and merge is separately gated by the merge-gate verdict and
|
||||
the coordinator, so a non-zero `CANNOT_ASSERT` strands nothing.
|
||||
|
||||
**Conditions.** Distinct exit code (`CANNOT_ASSERT` never conflated with `ASSERTED_NOT_READY`, or the
|
||||
tri-state is destroyed) · **the audit record is ASSERTED by a registered case, not assumed** — otherwise
|
||||
"audited" is an integrity _claim_ dressed as a _property_ · retryable and self-clearing, documented ·
|
||||
registered cases observed RED first for each arm · **no silent degraded merge path, ever** — any
|
||||
authorised degraded merge is break-glass (loud, audited, expiring) and belongs to RM-05, not here.
|
||||
|
||||
**Option C (always non-zero) rejected:** cleaner to specify, worse in practice. It bricks push during an
|
||||
outage and defers the degraded path to "later", which in this codebase means **a silent bypass appears
|
||||
under incident pressure**. Three such bypasses are already on the books; do not create the conditions
|
||||
for a fourth.
|
||||
|
||||
### RM-61 — the terminal-green exemption: ruled B, with a sequencing correction
|
||||
|
||||
**Ruling (Mos, 2026-08-01):** exempt the #1000 teardown artifact from the terminal-green scan via a
|
||||
**named, bounded, tracked** CI-contract exemption — signature-scoped, negative-control-proven, retiring
|
||||
when #1000 is fixed. Rejected: **A-first** (blocks all delivery on infra work) and **C** (case-by-case
|
||||
acceptance = silent normalisation with no audit trail).
|
||||
|
||||
**Condition 1 tested — the signature IS stable.** All five observed failures share one shape:
|
||||
|
||||
```
|
||||
pods "wp-svc-<ULID>-ci-postgres" not found # #2170 #2175 #2180 #2181 #2182
|
||||
```
|
||||
|
||||
A Kubernetes **orchestration-layer lookup miss** — the pod object was gone when status was collected —
|
||||
structurally unlike a service-level failure (container exit code, image-pull error, crash, connection
|
||||
refused).
|
||||
|
||||
> ⚠ **BUT THE DISCRIMINATION IS UNPROVEN, AND THIS REORDERS THE CONDITIONS.**
|
||||
> Every observation is of the artifact co-occurring with a **demonstrably working** database — #2180/#2181
|
||||
> test logs show PostgreSQL accepting connections and migrations completing. **We have never observed a
|
||||
> real `ci-postgres` failure on this provider.** So the signature is _consistent_; it is not yet shown to
|
||||
> _discriminate_.
|
||||
>
|
||||
> The dangerous case is concrete: if PostgreSQL genuinely crashes and the pod is garbage-collected, the
|
||||
> status query may **also** return pod-not-found — in which case the exemption **over-matches and masks a
|
||||
> real failure**, which is precisely what condition 1 exists to prevent.
|
||||
>
|
||||
> **Therefore conditions 1 and 2 are not independent — condition 2 is the _evidence for_ condition 1.**
|
||||
> Correct order: **build the negative control first**, use it to establish that a real failure produces a
|
||||
> _different_ signature, and only then adopt the exemption. Adopting first and testing after would run the
|
||||
> exemption live on an unverified assumption.
|
||||
>
|
||||
> **Kill criterion, stated in advance:** if an injected real `ci-postgres` failure also yields
|
||||
> pod-not-found, the signature does **not** discriminate, **B is unsafe, and we do A** (fix #1000). Naming
|
||||
> the falsifier before running the test is the point — an exemption we cannot prove wrong is an exemption
|
||||
> we cannot trust.
|
||||
|
||||
**Ownership gap:** unassigned. `f10-coder` is on RM-02's blockers, `coder-mos1` on RM-03, `rev-974` is the
|
||||
reviewer, and no other seat holds mosaicstack write. Flagged to the coordinator rather than stacked onto a
|
||||
busy lane. Not blocking today — RM-02's code blockers are independently disqualifying — but the next
|
||||
otherwise-clean PR meets this.
|
||||
|
||||
### RM-02 CI artifact — the bounded re-trigger is a STOPGAP, not a policy
|
||||
|
||||
Recorded so it cannot quietly become practice.
|
||||
|
||||
RM-02's pipeline #2187 came back **overall success with `ci-postgres` FAIL** — every functional child
|
||||
green, `gate-verify` included. RM-61's exemption is not built, so **no authority to exempt it exists**.
|
||||
The orchestrator used **one bounded, pre-declared CI re-trigger** at the same head as an interim, with the
|
||||
response to each outcome fixed _before_ triggering:
|
||||
|
||||
- **clean** ⇒ a genuine terminal-green **observation** at that head (the mandate says green _at the head_,
|
||||
not green _on the first roll_) ⇒ report gate-ready;
|
||||
- **fails again** ⇒ RM-02 **waits for RM-61**. No ad-hoc exemption. **No third roll.**
|
||||
|
||||
**Why it was declared in advance.** Re-running until the desired answer appears is **p-hacking the
|
||||
pipeline**, and done silently _nobody can distinguish it from diligence — including the person doing it_.
|
||||
A bounded attempt count with a pre-declared response to each outcome is the only defence, and it is the
|
||||
same discipline as naming a kill criterion before a test or committing acceptance checks before reading a
|
||||
diff.
|
||||
|
||||
**Guardrail (Mos), non-negotiable.** **This must not become standing practice.** A per-PR "free re-roll"
|
||||
on the artifact is **D-21 normalisation wearing a new hat** — routine artifact becomes routine re-roll,
|
||||
and the erosion is identical. It is a one-time interim **for this PR**, under declared uncertainty, with
|
||||
RM-61 as the real fix. **RM-61 must make the re-roll unnecessary, not codify it** — build the
|
||||
discrimination, not a retry mechanism.
|
||||
|
||||
**A clean re-run does NOT retire RM-61.** The re-roll is a die whose bias remains unmeasured; only RM-61's
|
||||
negative control measures it. RM-61 stays required regardless of this run's outcome.
|
||||
|
||||
> **OUTCOME — the re-run FAILED, and the declared response was honoured.** Pipeline **#2188**, same head
|
||||
> `f9746b23`, JSON scan 11 entries: `ci-postgres` the **sole** failure, every other child success
|
||||
> including `gate-verify`. Confirmed independently by `f10-coder`, which applied the pre-registered rule
|
||||
> itself — _"I do not call terminal-green, do not request a third roll, and do not infer an exemption."_
|
||||
>
|
||||
> **RM-02 is BLOCKED on RM-61.** No ad-hoc exemption. **No third roll.**
|
||||
>
|
||||
> Worth stating why not, because the temptation was legible: two failures against one earlier success
|
||||
> (#2184 was clean at the previous head) makes _"best of five"_ feel reasonable. **The entire point of
|
||||
> declaring the bound in advance is that it binds when the result is inconvenient.** A bound honoured
|
||||
> only when it agrees with you is not a bound — and a third roll would have been indistinguishable from
|
||||
> diligence, _including to the person doing it_.
|
||||
>
|
||||
> **Data point for RM-61:** the artifact hit **twice consecutively** at `f9746b23` while #2184 was clean
|
||||
> at `38f1b249` — so it may **cluster** rather than being uniformly random per run. That makes the single
|
||||
> bounded re-roll a _weaker_ mitigation than it appeared, and strengthens the ruling that it must never
|
||||
> become policy: **if failures cluster, a one-shot re-roll is close to worthless.**
|
||||
|
||||
### The sharpest illustration the mission has produced
|
||||
|
||||
**RM-02 — the anti-inert-gate registry, built so a gate that has silently stopped enforcing can be
|
||||
DETECTED — is itself blocked by an infrastructure artifact that makes gate evidence unreliable.**
|
||||
|
||||
Its code is complete. Its own harness passes in CI. Two independent reviews cleared its substance. It
|
||||
cannot be certified terminal-green because the CI record contains a failure **nobody can yet prove is
|
||||
meaningless**.
|
||||
|
||||
That is not an obstacle to the mission. It is the mission's thesis demonstrating itself on its own
|
||||
keystone deliverable.
|
||||
|
||||
### ★ MILESTONE — the full merge stack completed end-to-end for the first time (RM-03 / PR #1032)
|
||||
|
||||
`independent review → CI terminal-green at the exact head → merge-gate verdict → coordinator → held for owner`
|
||||
|
||||
The merge-gate returned **GO** (comment 20392, posted under its **own minted identity** — the #3084
|
||||
fail-open-to-admin foreclosed), enumerating: JSON **9/9** step scan, head triple equal at `78ec47cd`,
|
||||
review **id 66 APPROVED** at the current head, `Closes #1019`, **`queue-guard: ZERO-INFORMATION`**, and
|
||||
head stability post-verdict. Verified independently after the verdict: the head has **not** moved, so the
|
||||
commit-bound GO remains valid.
|
||||
|
||||
**What makes it worth marking:** every element was _observed_, none relayed; the queue-guard field was
|
||||
recorded as the meaningless value it currently is rather than cited as evidence; and the verdict was
|
||||
posted durably by an identity that **structurally cannot merge** (`push=False`). The gate that authorises
|
||||
merges cannot perform them.
|
||||
|
||||
And the shape holds: **RM-03 is the fix for the queue guard, so this — the first delivery through the
|
||||
complete stack — is also the last one gated by a check that could not fail.**
|
||||
|
||||
### D-35 — render-not-restate was enforced by a TOOL where attention had failed three times
|
||||
|
||||
While editing `roles.local/merge-gate.md` to fix D-33, the coordinator **recalled the mandate string
|
||||
instead of reading it** — and the Edit tool's **exact-match requirement rejected the edit**. The source
|
||||
was then re-read and the change applied correctly.
|
||||
|
||||
**Third instance for that author, occurring inside the turn that was fixing a different instance of the
|
||||
same class.** It did not reach the record, because a **mechanism** caught what attention had not.
|
||||
|
||||
**This is the argument for RM-02 in miniature, demonstrated live.** The rule has now failed in both its
|
||||
authors, repeatedly, under maximal attention, while each was actively enforcing it on the other (D-26,
|
||||
D-30, and this). Every failure that _reached_ a document was caught by a human noticing later; **the one
|
||||
failure that did not reach a document was caught by a tool refusing an inexact match.**
|
||||
|
||||
> **A rule that fails in its authors but is caught by a tool has told you exactly where it belongs.**
|
||||
|
||||
**Requirement.** This is the pattern RM-02 generalises: exact-match/verbatim enforcement on citations and
|
||||
authoritative text, so a recalled-but-wrong quotation **cannot be written at all** rather than being
|
||||
detected downstream. The Edit tool did it by accident of design; the registry should do it by intent.
|
||||
|
||||
**Fix landed durably, and by the right method:** the coordinator annotated the _source_ —
|
||||
`merge-gate.md` mandate 3 now requires the full step scan **from the JSON/API record**, names the
|
||||
default-text `clone`-pruning as D-33, and requires the gate to **state the count it observed**; mandate 4
|
||||
requires the count read from JSON, with _"a count that does not match the JSON record is
|
||||
non-conforming."_ Every future gate assignment inherits it — **annotate the source, do not restate per
|
||||
assignment.**
|
||||
|
||||
**Deliberately not done:** the shared `pipeline-status.sh` wrapper was **not** modified. Other consumers
|
||||
depend on its current output; changing it mid-flight would be an unreviewed change to a shared tool at a
|
||||
moment when three lanes are reading it. Fix at a seam, tracked.
|
||||
|
||||
### D-34 — a context reset silently strips a seat's credential identity
|
||||
|
||||
`rev-974` completed its RM-02 review, then **could not post it**:
|
||||
`pr-review.sh … → "Gitea token not found for review post"`. It stopped per the wrapper-failure gate
|
||||
rather than reaching for a raw CLI — correctly.
|
||||
|
||||
Diagnosed rather than guessed:
|
||||
|
||||
| environment | `get_gitea_token git.mosaicstack.dev` |
|
||||
| -------------------------------------------------- | ------------------------------------- |
|
||||
| `MOSAIC_GIT_IDENTITY` unset (a freshly-reset seat) | **FAILS** — empty |
|
||||
| `MOSAIC_GIT_IDENTITY=rev-974` | resolves, 40 chars |
|
||||
|
||||
The token file was present and correct throughout. **The orchestrator's own context reset wiped the
|
||||
seat's exported identity**, and the rehydration brief did not re-establish it. The seat had posted
|
||||
reviews 61–64 earlier the same night because that export was still live.
|
||||
|
||||
**The split that makes this invisible:** `git config mosaic.gitIdentity` is **persistent and
|
||||
per-worktree**; `MOSAIC_GIT_IDENTITY` is **ephemeral and per-context**. A seat working inside a
|
||||
configured worktree survives a reset by falling back to git config. `rev-974` works from `~/agent-work`,
|
||||
which carries no such config — so it had **no fallback and no warning**, and the loss surfaced only at
|
||||
the moment it next tried to act on a credential.
|
||||
|
||||
**This is the intersection of two banked findings, and it was created by acting on one of them.** D-31
|
||||
prescribes rotation as the response to context exhaustion; D-11a establishes that identity must cohere.
|
||||
**Rotating a seat is a credential-affecting operation** — and nothing in the rotation procedure said so.
|
||||
The fix for one failure quietly introduced another.
|
||||
|
||||
**Requirements.** RM-34 (rotation daemon): a reset must **re-establish seat identity as part of the
|
||||
rotation**, not leave it to the rehydration prose — and must **verify** it (resolve a token, or assert
|
||||
the identity is set) before the seat is handed work. RM-50: prefer the **durable** binding — set
|
||||
`mosaic.gitIdentity` in each seat's working repo so identity survives a context reset by construction,
|
||||
rather than depending on an env var that a reset destroys. Interim: **every rehydration brief re-exports
|
||||
the seat identity**, and a rotated seat's first credential-touching action is treated as unverified until
|
||||
it succeeds once.
|
||||
|
||||
### D-33 — the tool used for "full step scans" silently prunes a step in its default output
|
||||
|
||||
`coder-mos1` reported a **9/9** child-step scan of pipeline #2186 using `-f json`. My scan of the same
|
||||
pipeline, using the same wrapper in its **default text mode**, showed **8**. Verified:
|
||||
|
||||
| mode | steps enumerated |
|
||||
| -------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `pipeline-status.sh -n 2186` (text, default) | **8** — begins at `ci-postgres` |
|
||||
| `pipeline-status.sh -n 2186 -f json` | **10** — the `ci` workflow entry, **`clone`**, plus those 8 |
|
||||
|
||||
**The default output omits `clone`.** Every "full step scan" performed tonight — RM-01, RM-02 across four
|
||||
pipelines, RM-03 — enumerated **8 of 9 real child steps**, and reported that as complete. `clone`
|
||||
succeeded in every case, so **no verdict changes**; the _method_ was incomplete and its user did not know.
|
||||
|
||||
**Why this is more than a cosmetic gap.** The merge-gate mandate requires CI verified _"by a **full step
|
||||
scan** — a `tests-complete` step is a pruning-tell, never a completion-tell"_, and requires every verdict
|
||||
to enumerate _"pipeline id + the step-scan result (step count, any SKIPs…)"_. **A verdict citing 8 steps
|
||||
where 9 exist is non-conforming evidence** — and if the gate uses this wrapper's default output, it will
|
||||
produce exactly that while believing it complied.
|
||||
|
||||
**The shape is the session's own thesis, aimed at the tool built to detect it.** The doc's instruction is
|
||||
literally _"read the artifact, not the summary."_ The text mode **is** a summary. It looks like an
|
||||
artifact — a labelled list of named steps with states — which is precisely why nobody checked it against
|
||||
the raw record. **A summary that resembles an enumeration is more dangerous than one that obviously
|
||||
summarises.** Compare D-24: `mergeable` was a _true answer to a different question_; here the step list is
|
||||
a _true answer to a smaller question_.
|
||||
|
||||
Caught only because a seat used `-f json` and its count disagreed with mine. **A disagreement between two
|
||||
observers surfaced it; neither observer alone would have.**
|
||||
|
||||
**Requirements.** RM-02 registers a case asserting the scan enumerates the **full** child-step set
|
||||
(compare text-mode count against the JSON record, must-fail on divergence). The merge-gate must scan from
|
||||
the **JSON/API record**, never the text summary, and state the step count it saw. The wrapper's default
|
||||
output should either include `clone` or **stop presenting itself as a complete step list**.
|
||||
|
||||
### D-32 — the repaired gate shipped with a documented switch to skip it
|
||||
|
||||
`rev-974` blocked RM-03 (PR #1032) on a single finding, and it is the one that mattered:
|
||||
**`pr-merge.sh` still ships `--skip-queue-guard`.**
|
||||
|
||||
Verified independently at five sites in `packages/mosaic/framework/tools/git/pr-merge.sh` — usage line
|
||||
`:3`, help text `:29` ("Skip CI queue guard wait before merge"), a worked **example** at `:38`, the flag
|
||||
handler `:58`, and the conditional wrapping the entire `ci-queue-wait.sh` invocation at `:119`.
|
||||
|
||||
**The reviewer did not report this from reading — it proved it.** It replaced the fixture queue guard
|
||||
with an `exit 99` stub, ran a real fixture merge with `--skip-queue-guard`, and observed: **the guard was
|
||||
never called, the provider payload was created, the wrapper printed "merged successfully", exit 0.**
|
||||
|
||||
**Why this is disqualifying rather than a nitpick.** RM-03 repairs a mandatory merge gate that has never
|
||||
once been able to fail (D-23). Shipping that repair while leaving a **documented, advertised,
|
||||
worked-example** flag that skips the repaired gate means the gate still cannot be relied upon — **it
|
||||
merely requires one flag instead of zero.** Every merge-side `CANNOT_ASSERT`/`HOLD` semantic the task
|
||||
built is bypassed by it. L0 is explicit: _"Never use workarounds that bypass quality gates — `--no-verify`
|
||||
and equivalent skip switches are off-limits."_ This is an equivalent skip switch, sitting in the merge path.
|
||||
|
||||
The merge-gate role doc names this exact hazard about `force_merge`, and the description transfers
|
||||
without modification:
|
||||
|
||||
> _a `--no-verify` equivalent, **adjacent to the field you came to edit, at the exact moment you are most
|
||||
> motivated to reach for it** — red gate, queued PR._
|
||||
|
||||
**The generalizable rule.** **Repairing a gate is incomplete while any supported path skips it.** A fix
|
||||
that restores a gate's ability to fail, but leaves a switch that turns it off, has changed the failure
|
||||
from _"the gate cannot block"_ to _"the gate can be told not to"_ — which under incident pressure is the
|
||||
same outcome reached one keystroke later. Removing the bypass is **part of the repair**, not a follow-up.
|
||||
|
||||
**Required:** remove `--skip-queue-guard` from merge-capable execution — flag, usage, help text and
|
||||
example alike (**an undocumented-but-working bypass is worse than a documented one**) — plus a RED-first
|
||||
regression proving a merge-capable invocation cannot reach a provider merge call with the guard unrun.
|
||||
`--dry-run` may retain the skip since it does not merge, provided dry-run and merge-capable paths can
|
||||
never both be true.
|
||||
|
||||
**Everything else in RM-03 passed independent verification**, including the 160 KiB stdin transport where
|
||||
#1023 regressed, the full classification matrix, the audited push-0/merge-75 tri-state, and the scope
|
||||
disposition. A strong PR with one hole in it.
|
||||
|
||||
### D-31 — a seat ran past 100% context with 28 uncommitted files and no commits
|
||||
|
||||
`coder-mos1` reached **102.5% / 372k context** on RM-03 with **28 modified files uncommitted** and its
|
||||
branch still at the base commit — i.e. **a full context window spent (~\$27) with nothing durable**. The
|
||||
orchestrator caught it by polling seat state, not by any signal from the system.
|
||||
|
||||
**Nothing warned anyone.** No threshold alert, no pre-compaction hook, no "you are about to lose this"
|
||||
prompt. The seat did not report distress because from inside, running low on context feels like ordinary
|
||||
work right up until it is not. **The one failure mode the mission was founded on — compaction destroying
|
||||
in-flight work — very nearly happened to the mission's own delivery, twice in one night** (RM-01 was
|
||||
checkpointed at 69%; this one was already past the window).
|
||||
|
||||
**What made RM-01 survivable and this one nearly not:** RM-01 had _committed_ work when it was
|
||||
checkpointed, so rotation was cheap. Here the work existed only in the worktree and the seat's context.
|
||||
**Commit-then-rotate works; rotate-without-commit loses everything.**
|
||||
|
||||
**Requirements.**
|
||||
|
||||
- **RM-34 (rotation daemon):** a context-threshold trigger is not enough — it must **force a durable
|
||||
checkpoint before rotating**, and refuse to rotate a seat with uncommitted work until that work is
|
||||
committed or explicitly discarded. Rotation without a commit is data loss with extra steps.
|
||||
- **RM-50 (fleet ownership):** seat context level is **observable state** and must be monitored, with a
|
||||
threshold alert to the coordinator. Relying on an orchestrator to poll is exactly the "instructions,
|
||||
not enforcement" pattern (D-4) applied to lifecycle.
|
||||
- **Worker briefs (immediate, mechanical):** _commit early, commit WIP, an imperfect commit that exists
|
||||
beats perfect work that vanishes._ Added to standing brief doctrine rather than left to judgement.
|
||||
|
||||
**Adjacent observation — scope drift, unverified.** The same diff spans 5 framework guides and 11 agent
|
||||
templates against a brief scoped to the queue guard and its tests. Queried rather than assumed: if the
|
||||
edits are consequential to the tri-state change they stay; if drive-by, they split. Recorded here because
|
||||
**a 28-file diff from a brief that named one script is a scope signal regardless of the answer.**
|
||||
|
||||
### D-29 — the registry's own coverage clause was SYNTACTIC, not semantic
|
||||
|
||||
`rev-974` blocked PR #1030 with a finding that lands in the keystone's own enforcement:
|
||||
|
||||
> It **moved** `RM02-MEANING-PROVENANCE` and `RM02-PROSE-CONTROL` from the stale-build-lock case to an
|
||||
> **unrelated type-error case**, reformatted the manifest, and **`pnpm gate:verify` still exited 0.**
|
||||
|
||||
So the registry verifies that a criterion **has a binding**, not that the bound case **can fail for that
|
||||
criterion's stated reason**. That is exactly what **RM02-REQ-03 (clause 2, from D-17)** requires, and it
|
||||
is not satisfied — `gates/gates.manifest.json:471-488` + `scripts/gate-verify.mjs:410-417`.
|
||||
|
||||
**The keystone reproduced the defect it was built to eliminate, in its own coverage check.** D-17 said a
|
||||
green suite can leave a criterion untested; here the tool that enforces that clause enforces only its
|
||||
_syntax_. A binding that can be pointed anywhere without complaint is a binding that proves nothing —
|
||||
**D-24's "true answer to a different question" wearing a manifest.**
|
||||
|
||||
Caught only because the reviewer **mutated the binding and re-ran**, rather than reading the manifest and
|
||||
confirming the entries existed. Inspection would have passed it.
|
||||
|
||||
### D-30 — the orchestrator fed a reviewer a false comparator supporting its own conclusion
|
||||
|
||||
In the review brief for #1030 I stated the `ci-postgres` FAIL had "appeared under overall-success on
|
||||
**#2167**/#2170/#2175". `rev-974` refuted it: **#2167's `ci-postgres` step is `OK`.** Verified —
|
||||
#2158 `OK`, **#2167 `OK`**, #2170 `FAIL`, #2175 `FAIL`, #2182 `FAIL`.
|
||||
|
||||
**My own banked D-21 says so explicitly:** _"Observed on #2170 and #2175 (**not on #2158, #2167**, or the
|
||||
manual #2171)."_ I contradicted my own written record — because I **restated it from memory instead of
|
||||
reading it.** That is **D-26 recurring**, committed by the orchestrator, in a reviewer brief, hours after
|
||||
promoting render-not-restate to the charter.
|
||||
|
||||
**The specific harm is worse than the inaccuracy.** I supplied a reviewer with **fabricated supporting
|
||||
evidence for my own reading**, in a brief that also asked it to accept my `ci-postgres` interpretation.
|
||||
Had it deferred, the false comparator would have propagated into the review record and strengthened a
|
||||
conclusion it never supported. It refuted me instead — which is the query-for-refutation rule paying for
|
||||
itself in the same document that introduced it.
|
||||
|
||||
**Requirement.** A brief that supplies evidence must **cite it from the record, not from recall** —
|
||||
findings are quoted with their identifier, not paraphrased. RM-02 registers it: where a document cites a
|
||||
finding, the citation must match the banked text, with a must-fail control on divergence.
|
||||
|
||||
### D-28 — a swallowed diagnostic destroyed the evidence a fail-closed check needed
|
||||
|
||||
RM-02's CI run failed on four `scripts/gate-history.test.mjs` sandbox tests. **The fail-closed logic
|
||||
behaved correctly; the defect was that it was denied the evidence to decide.**
|
||||
|
||||
Chain: Bubblewrap is installed by the **gate** step but not the **test** step, so `spawnSync('bwrap', …)`
|
||||
returned `status=null` / `ENOENT`. `replayCommit` then **replaced the spawn diagnostic with an empty
|
||||
string** (`historical frozen dependency install failed:`). With the underlying error destroyed, the
|
||||
classifier **could not prove Bubblewrap provenance** — and, correctly, **refused** to treat an
|
||||
unprovable condition as expected sandbox unavailability.
|
||||
|
||||
**The refusal was right. The information loss was the bug.** A fail-closed check is only as good as the
|
||||
evidence reaching it: strip the diagnostic and a correct classifier is forced into a correct-but-opaque
|
||||
refusal that looks like a defect in the thing being tested. **Error text is not decoration — for a
|
||||
classifier it is the input.**
|
||||
|
||||
**Fix requirements** (and what review must scrutinise): preserve `install.error.message` in replay
|
||||
diagnostics, and accept **only Bubblewrap-provenance** `EPERM`/`EACCES`/`ENOENT` as terminal sandbox
|
||||
refusal — unrelated command errors stay rejected. **Widening the acceptance to make the test pass would
|
||||
be a real finding**, not a fix: it would convert a precise fail-closed check into a permissive one.
|
||||
|
||||
**Two linkages worth recording:**
|
||||
|
||||
- **D-16 again** — it did not reproduce locally because `bwrap` _is_ installed there. Local and CI
|
||||
disagreeing about what passing means, a third time.
|
||||
- **Diagnostic-preservation is a gate requirement, not hygiene.** RM-02 registers it: where a check
|
||||
classifies on the basis of an error, the case must assert the **diagnostic survives** to the
|
||||
classifier, with a must-fail control proving a swallowed message is detected.
|
||||
|
||||
**Process note.** The orchestrator's hypothesis — that the coincident `ci-postgres` FAIL caused the test
|
||||
failure — was **wrong**, and refuted with direct evidence: the log shows `ci-postgres:5432 - accepting
|
||||
connections` and migrations completing. **D-21 therefore stands unchanged as a teardown artifact and is
|
||||
NOT upgraded.** It was about to be re-classified as "intermittently takes out the test step" on a false
|
||||
premise; asking the seat to _confirm or refute_ rather than accept is what prevented a finding being
|
||||
corrupted by a plausible guess.
|
||||
|
||||
### D-27 — the authoritative role file certifies an inert control as `✅ enforced`
|
||||
|
||||
Found by `rev-974` while loading the canonical gate sources — i.e. found _because_ we switched from
|
||||
restating to reading (D-26). Verified independently in
|
||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md`:
|
||||
|
||||
| line | text | problem |
|
||||
| --------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| :59 (mandate 3) | verify "**CI queue guard clear**" from primary evidence | mandates verifying a check that cannot fail |
|
||||
| :75 (mandate 4) | every durable verdict must enumerate "**the queue-guard outcome**" | requires a meaningless field _in the evidence record_ |
|
||||
| :128-129 | coordinator merge step 2 runs `ci-queue-wait.sh --purpose merge`; annotated "**real**" | the sensor never fires |
|
||||
| **:290** | control table: **"CI queue guard \| ✅ enforced \| … genuinely aborts"** | **a security-control table certifying an inert control** |
|
||||
|
||||
**The precise defect, because the distinction matters.** The document is _correct about the wiring_ and
|
||||
_wrong about the control_. `set -euo pipefail` with an unguarded exit genuinely does abort — that
|
||||
plumbing is real. What is false is the conclusion `✅ enforced`, because **the guard cannot produce a
|
||||
non-zero exit for any input** (D-23). So this is **a mechanism correctly wired to a sensor that never
|
||||
fires**, documented as enforced in the governing role file — the inert-gate class, certified.
|
||||
|
||||
Note the compounding: the verdict format **requires** the queue-guard outcome as enumerated evidence.
|
||||
A conforming verdict is therefore obliged to include a field that means nothing — the role file
|
||||
**instructs the merge-gate to manufacture evidence from an inert check**, in the same mandate that
|
||||
exists to stop bare conclusions being recorded as verdicts.
|
||||
|
||||
**Interim resolution (confirmed with `rev-974`, with one refinement).** Follow the stricter D-23 ruling.
|
||||
But do **not omit** the queue-guard field — omitting a required field makes the verdict non-conforming.
|
||||
**Record it, labelled `ZERO-INFORMATION (inert, owner RM-03)`.** That satisfies the enumeration mandate
|
||||
and states the truth simultaneously; a silently-omitted field and a silently-cited one are both worse.
|
||||
|
||||
**Not ours to edit.** `roles.local/merge-gate.md` is an operator-owned override; the correction belongs
|
||||
to the coordinator. **Recommended fix is annotation, not deletion** — the requirement is _correct once
|
||||
RM-03 lands_, so removing it would create a gap the moment the guard starts working. Annotate the three
|
||||
evidence sites and correct `:290` to distinguish _wiring real_ from _control inert_.
|
||||
|
||||
**This is D-20's clause meeting an authoritative framework document.** Prose asserting a security
|
||||
property needs a negative control observed red; `✅ enforced` is exactly such an assertion, and it would
|
||||
fail that control today. The rule was written after the orchestrator overclaimed in its own governing
|
||||
document — it now catches the same defect in the framework's.
|
||||
|
||||
### D-26 — render-not-restate failed at maximal attention, committed by the rule's own enforcer
|
||||
|
||||
**The definitive justification for making render-not-restate mechanical.**
|
||||
|
||||
The mission's delivery-gate definition was **incomplete from setup**: the merge-gate verdict step was
|
||||
missing. Root cause, stated by the coordinator: when `MISSION.md` / `KICKSTART.md` were written, the
|
||||
gates were **restated from memory** — _"author ≠ reviewer, diff-blind checks, CI-green, merged-PR
|
||||
completion"_ — a subset of an authoritative document that was not open at the time. The omission then
|
||||
**propagated into every worker brief issued for the rest of the mission.**
|
||||
|
||||
**Then it happened again, in the correction.** The message correcting the restatement error was itself a
|
||||
restatement: delivered from memory, without the source open, and it dropped or altered five things —
|
||||
|
||||
| stated | actual (`fleet/roles.local/merge-gate.md`) |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| verdict is `APPROVE`/`BLOCK` | **`GO` / `NO-GO` / `HOLD`** |
|
||||
| — | **verdict is commit-bound and VOID the instant the head moves**; `HOLD` persists; an empty-commit answer to `NO-GO` must be re-issued as `NO-GO` |
|
||||
| — | the gate verifies mechanical gates **itself from primary evidence** (reviewed-SHA == CI-SHA == head, full step scan, `Closes #N`, queue guard) |
|
||||
| — | the verdict must be **posted durably, enumerating evidence**; a bare "GO — gates verified" is **non-conforming** |
|
||||
| cite `guides/CANONICAL-DELIVERY-CYCLE.md` | **that file does not exist** — the path was taken from another brief and cited without checking it resolves |
|
||||
|
||||
**And the omission was safety-relevant.** The role doc carries a hard install precondition: an unminted
|
||||
gate seat's credentials **fail open to the owner's admin account**, so its verdicts would post as admin —
|
||||
_"the exact mechanism of the #3084 breach this seat exists to prevent."_ Verified: gate identities existed
|
||||
for USC only, none for mosaicstack (D-15's class, on the seat that authorises merges). Assigning the gate
|
||||
at that moment would have reproduced the breach it was created to stop.
|
||||
|
||||
**Why this is the definitive case, and not just a third instance.** The failure occurred when every
|
||||
condition favoured success: **attention was maximal** (a deliberate correction), **the actor knew the rule
|
||||
best** (its author and enforcer), and **the subject was the rule itself** — the message was _about_
|
||||
render-vs-restate while committing it. A discipline that fails under those conditions does not fail from
|
||||
carelessness; it fails because **human recall of a structured document is lossy by construction, and the
|
||||
loss is silent.**
|
||||
|
||||
> **A rule that its own enforcer cannot follow while enforcing it is not a discipline. It is a
|
||||
> requirement for a mechanism.**
|
||||
|
||||
**Requirement.** Governing documents must **reference** authoritative sources, never paraphrase them, and
|
||||
**every reference must resolve** — a dangling pointer to a non-existent authority is _worse_ than a
|
||||
restatement, because it reads as authoritative and cannot be checked. RM-02 registers both: a link-resolution
|
||||
case (every cited authority path exists) with a must-fail control, and the D-20 clause that prose asserting a
|
||||
property needs a negative control.
|
||||
|
||||
**Resolved:** `MISSION.md` and `KICKSTART.md` now **reference** `fleet/roles.local/merge-gate.md` and
|
||||
`fleet/roles/validator.md` and state only the gate _order_. The precondition was cleared properly —
|
||||
`gitea-mosaicstack-merge-gate.token` minted at **least privilege**, independently verified by this
|
||||
orchestrator as the seat's own token: `pull=True, push=False, admin=False` — it **structurally cannot merge**,
|
||||
which is what a verdict authority must be.
|
||||
|
||||
### D-25 — self-verification by the audited party, a second time, one layer down
|
||||
|
||||
RM-02's per-commit replay needs isolation. Docker denied Bubblewrap namespace creation; the obvious fix
|
||||
— a privileged CI step — was **correctly refused** by the implementing seat and rated CRITICAL by a
|
||||
security review, because **PR-controlled `package.json`/verifier code executes _before_ Bubblewrap
|
||||
establishes any boundary.** The untrusted party would obtain the very capability meant to contain it.
|
||||
UID 65534 and later capability drops do not repair an _ordering_ defect.
|
||||
|
||||
The seat's own formulation, promoted here verbatim because it is the whole argument:
|
||||
|
||||
> **"Repo-only code cannot both grant namespace capability to PR config and prevent that same PR from
|
||||
> using the capability directly."**
|
||||
|
||||
**This is D-19 again, one layer down — and the repetition is the finding.**
|
||||
|
||||
| | audited party controls… | so verification fails at… |
|
||||
| -------- | -------------------------------- | ------------------------- |
|
||||
| **D-19** | the manifest that certifies it | **artifact** integrity |
|
||||
| **D-25** | the code that enters the sandbox | **execution** integrity |
|
||||
|
||||
Both reduce to: **self-verification by the audited party is not verification.** Both resolve the same
|
||||
way — an authority _outside_ the audited party's control. **That is now twice this mission has
|
||||
independently derived the necessity of its own architecture (Builds 1–2: choke-point executor + spine)
|
||||
from a security impossibility rather than from design preference.**
|
||||
|
||||
**Ruling — Option C**, reached **independently** by the orchestrator and by `rev-974` before either saw
|
||||
the other's reasoning (the same convergence signal the two planners produced). Ship what the layer can
|
||||
guarantee — unprivileged, fail-closed, current-tree verification on PR CI — and defer isolated
|
||||
per-commit replay to a protected authority.
|
||||
|
||||
**Condition from rev-974 that the orchestrator's ruling missed, and it matters most:**
|
||||
**Protected post-merge replay is DETECTION, not PREVENTION.** It must be documented as such, with its
|
||||
response defined (quarantine / revert), and **must never be presented as equivalent to a pre-merge
|
||||
gate.** Presenting detection as prevention would be D-20's overclaim in a new location — and would be
|
||||
especially corrosive here, since the whole task exists to make gates trustworthy.
|
||||
|
||||
**Also binding:** inability to establish the sandbox must be a **hard non-zero**, never skip-or-pass;
|
||||
the meta-negative-control must not be weakened to fit the unprivileged environment (any gate whose
|
||||
mutation check genuinely cannot run unprivileged is registered as a `DEFECT` with an owner, never
|
||||
quietly dropped); and the privileged experiment stays **uncommitted**, out of branch history.
|
||||
|
||||
**Tracked dependency: RM-60** — an external pre-execution trust boundary (protected default-branch
|
||||
pipeline config, or an immutable trusted launcher) owned by infra/provider authority, cross-referenced
|
||||
with **RM-59**. Both are the same dependency wearing different clothes: _the anchor must live outside
|
||||
the audited party._
|
||||
|
||||
### D-24 — the orchestrator cited `mergeable: true` as evidence of merge-readiness
|
||||
|
||||
Escalating D-23, I wrote that the fix "exists and is parked, open and mergeable" — framing that invited
|
||||
the reading _merge this and the gate is fixed_. **Mos verified #1023 before relaying, and corrected me.**
|
||||
|
||||
`mergeable: true` is **git-mergeability**: the absence of textual conflicts. It says nothing about review
|
||||
state, CI, or correctness. Verified independently after the correction: **review id-58,
|
||||
`REQUEST_CHANGES`, at commit `f6334080` — the current head.** The block is live, and its findings are
|
||||
that #1023's _own fix_ is defective.
|
||||
|
||||
**This is the charter's first principle — observe the property, not the proxy — committed by the
|
||||
orchestrator, inside an escalation about a proxy being mistaken for a property.** I spent the session
|
||||
demanding that discipline of others, then read a JSON field whose name resembled the question I cared
|
||||
about and reported it as the answer. **Fifth instance of mine.**
|
||||
|
||||
Worth naming precisely, because it will recur: **`mergeable` is not a lie — it is a _true answer to a
|
||||
different question_.** A false field would have been caught. A true-but-adjacent field passes every sniff
|
||||
test, which is exactly why the discipline must be **mechanical rather than attentive**.
|
||||
|
||||
**Requirement on RM-02.** A registered case asserting readiness must assert the **decision-relevant
|
||||
property** — latest review state, CI terminal status on the exact head, required approvals — never a
|
||||
transport-level or structural proxy. A case that reads an adjacent field is D-17 (the set does not cover)
|
||||
in a convincing disguise.
|
||||
|
||||
### D-23 — the mandatory CI queue guard has never worked, for any input, ever
|
||||
|
||||
RM-02's registered fixtures found this **before the registry was even built** — the sharpest possible
|
||||
argument for the task. It is materially worse than the already-banked "unknown ⇒ exit 0" (D-6/D-10).
|
||||
|
||||
`ci-queue-wait.sh:266` pipes the provider payload into a classifier that begins `python3 - <<'PY'`.
|
||||
**`python3 -` reads its _program_ from stdin, and the heredoc supplies that program** — so
|
||||
`json.load(sys.stdin)` never sees the piped JSON, EOFs, and falls through to `unknown`.
|
||||
|
||||
Proven empirically, not inferred:
|
||||
|
||||
```
|
||||
payload={"state":"success"} -> unknown
|
||||
payload={"state":"failure"} -> unknown
|
||||
payload={"state":"pending"} -> unknown
|
||||
payload=not-json -> unknown
|
||||
```
|
||||
|
||||
**Every input classifies as `unknown`; `unknown` exits 0.** So the mandatory pre-push / pre-merge guard
|
||||
returns **PASS for every possible input — including a genuinely FAILING CI.** It has never distinguished
|
||||
anything. My six observed "meaningless greens" were not an edge case; they were **the only output it can
|
||||
produce.**
|
||||
|
||||
**A fix was ATTEMPTED and is parked — but it is NOT ready.** PR **#1023** — _"parse the payload it was handed, not stdin"_ —
|
||||
diagnoses the defect precisely in its own body: _"binds stdin to the program text, so
|
||||
`json.load(sys.stdin)` EOFs, the state falls through to `unknown`, and the guard exits 0 on every
|
||||
invocation, every branch, every repo, both platforms."_
|
||||
|
||||
Two aggravating details from that PR's own body:
|
||||
|
||||
1. **`pr-ci-wait.sh:38` documented this exact bug and its remedy — and it was never backported to the
|
||||
mandatory sibling.** A known defect, with a known fix, written down in one tool and never applied
|
||||
where it was load-bearing. That is D-14 propagation failure with a security-adjacent blast radius.
|
||||
2. It was _"opened per board instruction without re-verification"_ — the fix for an unverified gate was
|
||||
itself shipped unverified.
|
||||
|
||||
> ⚠ **CORRECTED (D-24), propagated backward per D-14-as-amended.** This entry originally read
|
||||
> _"The fix already exists and is parked … open, mergeable"_ — framing that invited _merge it and the
|
||||
> gate is fixed_. **`mergeable: true` is git-mergeability — the absence of conflicts — not readiness.**
|
||||
> Verified independently: #1023's latest review is **rev-974 id-58, `REQUEST_CHANGES`, at commit
|
||||
> `f6334080`, which IS the current head** — the block is **live, not superseded**. That review found
|
||||
> #1023's own fix defective: `unknown` still exits 0, payload-as-argv hits **ARG_MAX**, and its tests do
|
||||
> not assert exit outcomes. So "merge #1023 and the guard is fixed" is false twice over.
|
||||
|
||||
**Escalation, for Jason, on #1023's disposition.** Every merge gated on this guard since it was
|
||||
introduced was **ungated**. The guard cannot fail. This does not mean those merges were bad — CI itself
|
||||
ran and was checked by humans and reviewers — but the _automated_ gate contributed **zero** signal
|
||||
throughout, while being cited as merge evidence. The standing doctrine ("treat its green as
|
||||
zero-information") was more literally correct than anyone realised when it was written.
|
||||
|
||||
**RM-02 records `required` vs `actual` per case with `DEFECT (owner: RM-03)`** and stays sensitive to any
|
||||
change in either direction. No RM-03 lane was opened; `ci-queue-wait.sh` was not edited; source and
|
||||
installed copy remain byte-identical at the observed SHA-256.
|
||||
|
||||
### D-22 — the registry would have verified the wrong artifact
|
||||
|
||||
Surfaced while ruling RM-02's design. The gate that **actually runs** when the fleet invokes a wrapper is
|
||||
the **installed** copy:
|
||||
|
||||
```
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh ← what executes
|
||||
packages/mosaic/framework/tools/git/ci-queue-wait.sh ← what a repo-scoped registry would test
|
||||
```
|
||||
|
||||
Checked: today they are byte-identical (`sha256 19cda2f7009c536e` both). **But nothing asserts that.**
|
||||
There is no check, anywhere, that the deployed gate matches its source.
|
||||
|
||||
**A registry that verifies the wrong artifact is worse than no registry**, because it manufactures
|
||||
confidence: every case would go green against a file that is not the one enforcing anything. Note this
|
||||
would have been invisible from every signal we have — the registry's own tests would pass, CI would
|
||||
pass, and the deployed gate could be arbitrarily different.
|
||||
|
||||
This is the **D-1 / P-ACTIVATION class applied to the gates themselves**: the same "committed config
|
||||
that only works on one runtime" defect, one level up, where the _enforcement mechanism_ rather than the
|
||||
config drifts from source.
|
||||
|
||||
**Requirement on RM-02.** For every registered gate with a deployed counterpart, a case asserting
|
||||
**repo-source == deployed-copy**, with a must-fail control (mutate the deployed copy ⇒ detected). Where
|
||||
a gate has no deployed counterpart, record that fact explicitly rather than leaving it ambiguous.
|
||||
|
||||
**Caught before implementation began** — the only finding tonight found by _design review_ rather than
|
||||
by execution, which is an argument for the design-first gate on keystone tasks paying for itself.
|
||||
|
||||
### D-21 — a service step reports FAIL under an overall-success pipeline (normalised red)
|
||||
|
||||
Observed on pipelines **#2170** and **#2175** (not on #2158, #2167, or the manual #2171): the
|
||||
`ci-postgres` **service** step reports **FAIL** (`pods "wp-svc-…-ci-postgres" not found`) while the
|
||||
pipeline's overall status is **success** and all eight functional steps are `OK`.
|
||||
|
||||
Most likely mechanism: the service pod is reachable during the run and has been reaped by the time its
|
||||
terminal state is collected. Supporting evidence that the database really was available — the `test`
|
||||
step runs `pnpm --filter @mosaicstack/db run db:migrate` behind a `pg_isready` loop written to fail fast
|
||||
if Postgres never comes up, and that step passed.
|
||||
|
||||
**Severity is low; the pattern is not.** A red that is _routinely_ present and _routinely_ correct to
|
||||
ignore trains every operator and every agent to discount reds — and the discounting generalises to reds
|
||||
that matter. This mission already has six instances of a green that meant nothing (D-5, D-6, D-10);
|
||||
**a FAIL that means nothing is the same defect with the sign flipped.** Both erode the signal that gates
|
||||
exist to carry.
|
||||
|
||||
Not chased further because it is CI-infrastructure behaviour rather than repository code, and both
|
||||
merges it touched had every functional step green with the overall status successful. **Recorded rather
|
||||
than normalised**, which is the whole point: the first time this is waved through without a note is the
|
||||
moment it becomes background noise.
|
||||
|
||||
**Requirement on RM-55 (conformance harness):** treat a non-terminal-success sub-step under an
|
||||
overall-success pipeline as a **reportable anomaly**, not a cosmetic artifact — and make the CI contract
|
||||
state explicitly which steps are permitted to fail without failing the pipeline. An implicit allowance
|
||||
is indistinguishable from a bug.
|
||||
|
||||
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
|
||||
|
||||
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
|
||||
@@ -811,18 +1534,20 @@ spread is itself information, and X1 says we calibrate on real merged PRs.
|
||||
|
||||
### P5 — Retirements, hygiene, conformance
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ |
|
||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||
| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
||||
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------ | ---------------- | ------ |
|
||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||
| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
||||
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||
| RM-61 | **CI-contract exemption for the #1000 teardown artifact** — signature-scoped, negative-control-proven, bounded, retiring with #1000 (ruled B, Mos 2026-08-01) | mos-remediation | — (unassigned; no free write-capable seat) | 15K | sonnet |
|
||||
| RM-60 | **External pre-execution trust boundary for CI (option B — the correct primitive, not the cautious one)** — protected default-branch pipeline config or an immutable trusted launcher that enters the sandbox **before** any PR-controlled executable/config is evaluated; unblocks isolated per-commit replay (RM02-REQ-10) | mos-remediation (D-25) | infra/provider authority (Mos + Jason) | 25K | opus |
|
||||
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||
|
||||
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# RM-01 — Reproducible checkout
|
||||
|
||||
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
||||
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
||||
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
||||
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
||||
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
||||
- Plan:
|
||||
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
||||
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
||||
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
||||
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
||||
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
||||
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
||||
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
||||
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
||||
|
||||
## Checkpoint evidence (c45e5e19)
|
||||
|
||||
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
||||
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
||||
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
||||
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
||||
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
||||
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
||||
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
||||
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
||||
|
||||
## Continuation evidence
|
||||
|
||||
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
||||
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
||||
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
||||
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
||||
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||
|
||||
## Review remediation — restated AC2
|
||||
|
||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
||||
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
||||
|
||||
## Handoff
|
||||
|
||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
||||
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
||||
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
||||
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
||||
@@ -1,120 +0,0 @@
|
||||
# RM-03 — CI Queue Guard Repair
|
||||
|
||||
- **Task:** RM-03
|
||||
- **Issue:** #1019
|
||||
- **Branch:** `fix/rm-03-queue-guard`
|
||||
- **Owner:** coder-mos1
|
||||
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||
- **Started:** 2026-08-01
|
||||
|
||||
## Objective
|
||||
|
||||
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||
- No bypass flags or hook suppression.
|
||||
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||
- No merge: coordinator holds the merge hand pending Jason.
|
||||
|
||||
## Design
|
||||
|
||||
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Case | Required outcome |
|
||||
| --- | --- |
|
||||
| success | exit 0; terminal-success |
|
||||
| pending | nonzero after bounded timeout |
|
||||
| failure | nonzero |
|
||||
| no-status | nonzero |
|
||||
| malformed | nonzero |
|
||||
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||
| audit unavailable | nonzero |
|
||||
| implicit push branch | provider URL uses checked-out feature branch |
|
||||
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||
|
||||
## RED-first evidence
|
||||
|
||||
Observed against the unmodified `origin/main` implementation before source edits:
|
||||
|
||||
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||
- Success payload was reported `state=unknown`.
|
||||
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||
- Audit-unavailable emitted no audit diagnostic.
|
||||
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||
|
||||
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||
- [x] Isolated worktree created and identity configured coherently.
|
||||
- [x] Mutant tests authored and observed red.
|
||||
- [x] Implementation green.
|
||||
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||
- [ ] Merge-gate verdict issued against frozen head.
|
||||
|
||||
## Scope disposition
|
||||
|
||||
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||
|
||||
## Review remediation
|
||||
|
||||
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||
|
||||
## Risks / boundaries
|
||||
|
||||
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||
|
||||
## Test evidence
|
||||
|
||||
Fresh after rescue checkpoint `b7175012`:
|
||||
|
||||
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||
- `bash -n` on the three production shell scripts passed.
|
||||
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||
- `pnpm format:check` passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||
|
||||
## Final evidence
|
||||
|
||||
Pending.
|
||||
+3
-6
@@ -6,14 +6,11 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
||||
@@ -893,16 +893,14 @@ Woodpecker note:
|
||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||
|
||||
## Gitea as Unified Platform
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
||||
|
||||
## Review Checklist
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||
> without asking.
|
||||
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||
|
||||
@@ -425,11 +425,11 @@ git push
|
||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- Merge via wrapper:
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- Wait for terminal CI status:
|
||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- Close linked issue after merge + green CI:
|
||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
|
||||
## Git Scripts
|
||||
|
||||
@@ -638,9 +638,8 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||
|
||||
|
||||
@@ -23,12 +23,10 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
# CI queue guard (required before push/merge)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -7,9 +7,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
BRANCH="main"
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -17,12 +15,10 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
@@ -31,65 +27,63 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
get_state_from_status_json() {
|
||||
# Python source comes from -c so the provider payload remains on stdin.
|
||||
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||
python3 -c '
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
except Exception:
|
||||
print("malformed")
|
||||
print("unknown")
|
||||
raise SystemExit(0)
|
||||
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
statuses = raw_statuses
|
||||
state = raw_state.lower()
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
|
||||
pending_values = {"pending", "queued", "running", "waiting"}
|
||||
failure_values = {"failure", "error", "failed"}
|
||||
success_values = {"success"}
|
||||
|
||||
if state in pending_values:
|
||||
print("pending")
|
||||
raise SystemExit(0)
|
||||
if state in failure_values:
|
||||
print("terminal-failure")
|
||||
raise SystemExit(0)
|
||||
if state in success_values:
|
||||
print("terminal-success")
|
||||
raise SystemExit(0)
|
||||
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
raw_value = item.get("status") or item.get("state")
|
||||
if not isinstance(raw_value, str) or not raw_value:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||
print("terminal-success")
|
||||
elif not values:
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
print("pending")
|
||||
elif any(v in failure_values for v in values):
|
||||
print("terminal-failure")
|
||||
elif values and all(v in success_values for v in values):
|
||||
print("terminal-success")
|
||||
else:
|
||||
print("unknown")
|
||||
'
|
||||
PY
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 -c '
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -110,61 +104,17 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||
target = item.get("target_url") or item.get("url") or ""
|
||||
if value in pending_values:
|
||||
found = True
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
if not found:
|
||||
print("[ci-queue-wait] no pending contexts")
|
||||
'
|
||||
}
|
||||
|
||||
record_cannot_assert() {
|
||||
local reason="$1"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": "CANNOT_ASSERT",
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" == "merge" ]]; then
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||
return 75
|
||||
fi
|
||||
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
@@ -178,87 +128,7 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
local work_root status_file checks_file
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||
mkdir -p "$work_root" || return 1
|
||||
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||
rm -f "$status_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$status_file" "$checks_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
status_pages = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
check_pages = json.load(handle)
|
||||
|
||||
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||
raise SystemExit(1)
|
||||
|
||||
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||
# Keep only the newest entry per context after flattening every page.
|
||||
combined = []
|
||||
seen_contexts = set()
|
||||
for page in status_pages:
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
for status in page:
|
||||
if not isinstance(status, dict):
|
||||
raise SystemExit(1)
|
||||
context = status.get("context")
|
||||
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||
continue
|
||||
seen_contexts.add(context)
|
||||
combined.append(status)
|
||||
|
||||
check_runs = []
|
||||
reported_total = 0
|
||||
for page in check_pages:
|
||||
if not isinstance(page, dict):
|
||||
raise SystemExit(1)
|
||||
page_runs = page.get("check_runs") or []
|
||||
total_count = page.get("total_count")
|
||||
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||
raise SystemExit(1)
|
||||
reported_total = max(reported_total, total_count)
|
||||
check_runs.extend(page_runs)
|
||||
if len(check_runs) < reported_total:
|
||||
raise SystemExit(1)
|
||||
|
||||
for run in check_runs:
|
||||
if not isinstance(run, dict):
|
||||
raise SystemExit(1)
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
if status != "completed":
|
||||
value = "pending"
|
||||
elif conclusion == "success":
|
||||
value = "success"
|
||||
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||
value = "failure"
|
||||
else:
|
||||
value = "unknown"
|
||||
combined.append({
|
||||
"context": run.get("name") or "github-check",
|
||||
"status": value,
|
||||
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||
})
|
||||
|
||||
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||
PY
|
||||
local status=$?
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return "$status"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
}
|
||||
|
||||
gitea_get_branch_head_sha() {
|
||||
@@ -304,14 +174,6 @@ while [[ $# -gt 0 ]]; do
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-R|--repo)
|
||||
TARGET_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sha)
|
||||
HEAD_SHA="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -344,89 +206,45 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||
echo "Error: --repo must be OWNER/REPO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||
record_cannot_assert "repository-owner-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||
record_cannot_assert "repository-name-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! detect_platform > /dev/null; then
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
|
||||
if [[ -n "$TARGET_REPO" ]]; then
|
||||
OWNER="${TARGET_REPO%%/*}"
|
||||
REPO="${TARGET_REPO##*/}"
|
||||
fi
|
||||
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||
record_cannot_assert "current-branch-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||
record_cannot_assert "remote-host-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||
record_cannot_assert "credential-unresolvable"
|
||||
exit $?
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine remote host." >&2
|
||||
exit 1
|
||||
}
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -435,20 +253,14 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
else
|
||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -459,24 +271,21 @@ while true; do
|
||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
terminal-success)
|
||||
exit 0
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 3
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
;;
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -25,14 +25,15 @@ Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -52,13 +53,14 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
@@ -84,36 +86,18 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-B "$BASE_BRANCH" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -122,22 +106,31 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
is_known_tea_empty_identity_failure() {
|
||||
local error_file="$1"
|
||||
|
||||
python3 - "$error_file" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
error = handle.read()
|
||||
|
||||
known_empty_identity = re.search(
|
||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||
error,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
raise SystemExit(0 if known_empty_identity else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
head_sha, delete_branch = sys.argv[1:]
|
||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||
if delete_branch == "true":
|
||||
payload["delete_branch_after_merge"] = True
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
payload='{"Do":"squash"}'
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
@@ -209,7 +202,7 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -218,9 +211,32 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -109,7 +109,7 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,25 +182,6 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||
data.get('head_ref'),
|
||||
head_ref,
|
||||
)
|
||||
head_sha = first_non_empty(
|
||||
nested(data, 'head', 'sha'),
|
||||
nested(data, 'head', 'id'),
|
||||
data.get('head_sha'),
|
||||
)
|
||||
head_repo = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'full_name'),
|
||||
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||
)
|
||||
if not head_repo:
|
||||
head_repo_owner = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||
nested(data, 'head', 'repo', 'owner_name'),
|
||||
)
|
||||
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||
if head_repo_owner and head_repo_name:
|
||||
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||
|
||||
base_ref = first_non_empty(
|
||||
nested(data, 'base', 'ref'),
|
||||
nested(data, 'base', 'name'),
|
||||
@@ -226,8 +207,6 @@ normalized = {
|
||||
'state': data.get('state'),
|
||||
'author': nested(data, 'user', 'login') or '',
|
||||
'headRefName': head_ref,
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# Covers:
|
||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,7 +62,7 @@ case "$mode" in
|
||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||
500) code=500; body='{"message":"internal server error"}' ;;
|
||||
no-status) code=200; body='{}' ;;
|
||||
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
@@ -91,7 +91,6 @@ run_ci_queue_wait() {
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||
)
|
||||
}
|
||||
@@ -132,26 +131,19 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -ne 0 ]]; then
|
||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||
if [[ "$status_c" -eq 0 ]]; then
|
||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
endpoint=""
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||
done
|
||||
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||
case "$endpoint" in
|
||||
repos/acme/widgets/branches/fix/github-checks)
|
||||
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||
printf '%s\n' '[[]]'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/gh"
|
||||
|
||||
run_guard() {
|
||||
local mode="$1"
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_case() {
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||
set +e
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/gh-calls.log"
|
||||
assert_case success zero terminal-success
|
||||
assert_case pending nonzero pending
|
||||
assert_case failure nonzero terminal-failure
|
||||
assert_case late-failure nonzero terminal-failure
|
||||
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
url=""
|
||||
has_write_out=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_write_out=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_write_out" -eq 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||
malformed) printf '%s' 'not-json' ;;
|
||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||
large-success)
|
||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||
;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
local audit_log="${2:-$AUDIT_LOG}"
|
||||
shift 2 || true
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||
export HOME="$WORK_DIR/empty-home"
|
||||
mkdir -p "$HOME"
|
||||
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||
export MOSAIC_STUB_STATUS_MODE=success
|
||||
else
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
shift 4
|
||||
set +e
|
||||
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
case "$expected_rc" in
|
||||
zero)
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
nonzero)
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
not126)
|
||||
if [[ "$rc" -eq 126 ]]; then
|
||||
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [[ "$output" != *"$required_text"* ]]; then
|
||||
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion success zero success 'state=terminal-success'
|
||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
set +e
|
||||
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||
merge_unreachable_rc=$?
|
||||
set -e
|
||||
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||
set +e
|
||||
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||
unsupported_rc=$?
|
||||
set -e
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||
mkdir -p "$WORK_DIR/not-a-directory"
|
||||
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||
set +e
|
||||
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||
audit_failure_rc=$?
|
||||
set -e
|
||||
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -79,24 +79,15 @@ emit_response() {
|
||||
printf '200'
|
||||
fi
|
||||
}
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
POST_DATA="$post_data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = json.loads(os.environ["POST_DATA"])
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||
}, payload
|
||||
PY
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -116,8 +107,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -136,6 +127,38 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||
if [[ "$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"pr merge"* ]]; then
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
: > "$LOG_FILE"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -154,8 +177,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -192,7 +215,7 @@ cd "$REPO_DIR"
|
||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -217,7 +240,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -237,4 +260,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||
echo "pr-merge.sh Gitea fallback regression passed"
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||
SHA=0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
make_fixture() {
|
||||
local name="$1" remote="$2"
|
||||
local root="$WORK_DIR/$name"
|
||||
local tools="$root/tools/git"
|
||||
mkdir -p "$tools" "$root/repo"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||
git -C "$root/repo" init -q
|
||||
git -C "$root/repo" remote add origin "$remote"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$tools"/*.sh
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||
make_fixture github https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
if [[ "${!i}" == "-d" ]]; then
|
||||
j=$((i + 1))
|
||||
payload="${!j}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||
printf '200'
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/curl"
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/gitea.out" 2>&1
|
||||
gitea_rc=$?
|
||||
set -e
|
||||
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||
cat "$WORK_DIR/gitea.out" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||
assert payload.get("Do") == "squash", payload
|
||||
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||
PY
|
||||
|
||||
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||
stale_rc=$?
|
||||
set -e
|
||||
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 99
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||
bypass_rc=$?
|
||||
set -e
|
||||
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dry-run is the only path that may omit the guard because it exits before the
|
||||
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||
SH
|
||||
chmod +x "$WORK_DIR/github/gh"
|
||||
(
|
||||
cd "$WORK_DIR/github/repo"
|
||||
export PATH="$WORK_DIR/github:$PATH"
|
||||
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/github.out" 2>&1
|
||||
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||
cat "$WORK_DIR/github-call.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||
) >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [[ "$rc" -ne 42 ]]; then
|
||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$CALL_LOG" ]]; then
|
||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
||||
try {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (allowMissing && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireBuildLock(root) {
|
||||
const workRoot = path.join(root, '.mosaic-test-work');
|
||||
const lock = path.join(workRoot, 'web-build.lock');
|
||||
const nonce = randomUUID();
|
||||
const owner = JSON.stringify({ pid: process.pid, nonce });
|
||||
const deadline = Date.now() + 120_000;
|
||||
await mkdir(workRoot, { recursive: true });
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await mkdir(lock);
|
||||
await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 });
|
||||
return async () => {
|
||||
const current = await readFile(path.join(lock, 'owner.json'), 'utf8');
|
||||
if (current !== owner) throw new Error('Web build lock ownership changed before release.');
|
||||
const released = `${lock}.released-${nonce}`;
|
||||
await rename(lock, released);
|
||||
await rm(released, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
let lockOwner;
|
||||
try {
|
||||
lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
|
||||
} catch (ownerError) {
|
||||
if (ownerError.code === 'ENOENT') {
|
||||
await delay(25);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError });
|
||||
}
|
||||
try {
|
||||
process.kill(lockOwner.pid, 0);
|
||||
} catch (processError) {
|
||||
if (processError.code !== 'ESRCH') throw processError;
|
||||
const stale = `${lock}.stale-${nonce}`;
|
||||
try {
|
||||
await rename(lock, stale);
|
||||
await rm(stale, { recursive: true, force: true });
|
||||
} catch (renameError) {
|
||||
if (renameError.code !== 'ENOENT') throw renameError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for the web build lock at ${lock}.`);
|
||||
}
|
||||
|
||||
export async function buildWeb({
|
||||
root = scriptRoot,
|
||||
fingerprint = sourceFingerprint,
|
||||
runBuild = async (webDir) =>
|
||||
run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], {
|
||||
cwd: webDir,
|
||||
stdio: 'inherit',
|
||||
}),
|
||||
} = {}) {
|
||||
const releaseLock = await acquireBuildLock(root);
|
||||
try {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const nextDir = path.join(webDir, '.next');
|
||||
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
||||
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
||||
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
||||
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
||||
const before = await fingerprint(root);
|
||||
|
||||
await requireRealDirectory(nextDir, { allowMissing: true });
|
||||
await Promise.all([
|
||||
rm(certificationMarker, { force: true }),
|
||||
rm(symlinkManifest, { force: true }),
|
||||
]);
|
||||
await runBuild(webDir);
|
||||
await requireRealDirectory(nextDir);
|
||||
|
||||
const after = await fingerprint(root);
|
||||
if (after !== before) {
|
||||
throw new Error(
|
||||
'Web build inputs changed during next build; generated output was not certified.',
|
||||
);
|
||||
}
|
||||
|
||||
const manifestContents = await generatedSymlinkManifest(nextDir);
|
||||
const certificationContents = `${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: before,
|
||||
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
||||
})}\n`;
|
||||
await Promise.all([
|
||||
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
||||
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
||||
]);
|
||||
// The certification marker is the commit point. Publishing the manifest first
|
||||
// leaves interrupted builds untrusted because the marker remains absent.
|
||||
await rename(manifestTemporary, symlinkManifest);
|
||||
await rename(certificationTemporary, certificationMarker);
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await buildWeb();
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildWeb } from './build-web.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`);
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a successful web build atomically publishes its source and symlink certification', async () => {
|
||||
const root = await fixture('success');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
|
||||
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
||||
|
||||
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
|
||||
version: 1,
|
||||
sourceFingerprint: 'certified',
|
||||
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
|
||||
});
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a failed web build leaves no certification marker', async () => {
|
||||
const root = await fixture('failure');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'before',
|
||||
runBuild: async () => {
|
||||
throw new Error('build failed');
|
||||
},
|
||||
}),
|
||||
/build failed/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
|
||||
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
||||
const root = await fixture('overlap');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
let releaseFirst;
|
||||
let secondEntered = false;
|
||||
const firstEntered = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered;
|
||||
const firstStarted = new Promise((resolve) => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
|
||||
const first = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
markFirstEntered();
|
||||
await firstEntered;
|
||||
},
|
||||
});
|
||||
await firstStarted;
|
||||
const second = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
secondEntered = true;
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
assert.equal(secondEntered, false);
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(secondEntered, true);
|
||||
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
|
||||
const root = await fixture('symbolic-next');
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
const outside = path.join(root, 'outside-generated');
|
||||
await mkdir(outside);
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
await rm(nextDir, { recursive: true });
|
||||
await symlink(outside, nextDir);
|
||||
},
|
||||
}),
|
||||
/must be a real directory/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
|
||||
});
|
||||
|
||||
test('inputs changed during a web build are not certified', async () => {
|
||||
const root = await fixture('changed-inputs');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
const fingerprints = ['before', 'after'];
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => fingerprints.shift(),
|
||||
runBuild: async () => {},
|
||||
}),
|
||||
/inputs changed during next build/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, mkdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const generated = path.join(root, 'apps', 'web', '.next');
|
||||
const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine');
|
||||
|
||||
try {
|
||||
await access(generated);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') process.exit(0);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`);
|
||||
try {
|
||||
await rename(generated, quarantine);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(quarantine, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`,
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySnapshot(root) {
|
||||
const snapshot = [];
|
||||
async function walk(current) {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const target = path.join(current, child.name);
|
||||
const relative = path.relative(root, target);
|
||||
const stats = await lstat(target);
|
||||
if (child.isDirectory()) {
|
||||
snapshot.push([relative, 'directory', stats.mode & 0o777]);
|
||||
await walk(target);
|
||||
} else {
|
||||
snapshot.push([
|
||||
relative,
|
||||
'file',
|
||||
stats.mode & 0o777,
|
||||
(await readFile(target)).toString('base64'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
async function directoriesMatch(left, right) {
|
||||
return (await directorySnapshot(left)) === (await directorySnapshot(right));
|
||||
}
|
||||
|
||||
export async function installHooks({
|
||||
root = process.cwd(),
|
||||
disabled = process.env.HUSKY === '0',
|
||||
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
|
||||
runHusky = async (_stagingHooks, stagingRepo) => {
|
||||
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
|
||||
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
|
||||
cwd: stagingRepo,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
},
|
||||
activateHooks = async () => {
|
||||
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
|
||||
},
|
||||
} = {}) {
|
||||
if (disabled) return;
|
||||
|
||||
const huskyDir = path.join(root, '.husky');
|
||||
const active = path.join(huskyDir, '_');
|
||||
const nonce = `${Date.now()}-${process.pid}`;
|
||||
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
|
||||
const stagingHooks = path.join(stagingRepo, '.husky');
|
||||
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
|
||||
await mkdir(huskyDir, { recursive: true });
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
|
||||
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
|
||||
let previousQuarantined = false;
|
||||
try {
|
||||
if ((await pathExists(active)) && !previousComplete) {
|
||||
await rename(active, quarantined);
|
||||
previousQuarantined = true;
|
||||
}
|
||||
await mkdir(stagingRepo, { recursive: true });
|
||||
await runHusky(stagingHooks, stagingRepo);
|
||||
const staged = path.join(stagingHooks, '_');
|
||||
if (!(await pathExists(path.join(staged, 'h')))) {
|
||||
throw new Error('husky did not produce its required h shim');
|
||||
}
|
||||
if (previousComplete) {
|
||||
if (!(await directoriesMatch(active, staged))) {
|
||||
throw new Error('existing complete hook set differs from the installed Husky version');
|
||||
}
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
} else {
|
||||
await rename(staged, active);
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
}
|
||||
await activateHooks();
|
||||
if (previousQuarantined) {
|
||||
try {
|
||||
await rm(quarantined, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupFailures = [];
|
||||
try {
|
||||
if (await pathExists(stagingRepo)) {
|
||||
await rename(stagingRepo, `${quarantined}-staging`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
|
||||
}
|
||||
const cleanup =
|
||||
cleanupFailures.length === 0
|
||||
? 'No partial hook set was activated.'
|
||||
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
|
||||
throw new Error(
|
||||
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
await installHooks();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { installHooks } from './install-hooks.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`);
|
||||
const quarantineRoot = path.join(fixtureRoot, 'quarantine');
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, '.husky'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an interrupted install quarantines the partial active hook set and fails loudly', async () => {
|
||||
const root = await fixture('interrupted');
|
||||
let restoredHooksPath = 'not-called';
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'partial');
|
||||
throw new Error('simulated interruption');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async (value) => {
|
||||
restoredHooksPath = value;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Hook installation failed/);
|
||||
assert.match(error.message, /pnpm install --frozen-lockfile/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_')), false);
|
||||
assert.equal(restoredHooksPath, 'not-called');
|
||||
const quarantined = await readdir(quarantineRoot);
|
||||
assert.equal(quarantined.length, 1);
|
||||
});
|
||||
|
||||
test('a failed replacement restores a previously complete active hook set', async () => {
|
||||
const root = await fixture('rollback');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'previous-complete');
|
||||
let previousRemainedActiveDuringStaging = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'),
|
||||
runHusky: async () => {
|
||||
previousRemainedActiveDuringStaging =
|
||||
(await readFile(activeShim, 'utf8')) === 'previous-complete';
|
||||
throw new Error('simulated replacement failure');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(previousRemainedActiveDuringStaging, true);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete');
|
||||
});
|
||||
|
||||
test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => {
|
||||
const root = await fixture('mismatch');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'old-complete');
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed.*pnpm install --frozen-lockfile/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'old-complete');
|
||||
});
|
||||
|
||||
test('a competing successful installer is not removed by the losing process', async () => {
|
||||
const root = await fixture('concurrent');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test("a competing installer that replaces this installer's active set is preserved", async () => {
|
||||
const root = await fixture('concurrent-after-rename');
|
||||
const active = path.join(root, '.husky', '_');
|
||||
const activeShim = path.join(active, 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
},
|
||||
activateHooks: async () => {
|
||||
await rm(active, { recursive: true, force: true });
|
||||
await mkdir(active, { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
throw new Error('our activation lost to peer');
|
||||
},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => {
|
||||
const root = await fixture('disabled');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'preserved');
|
||||
let ran = false;
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
disabled: true,
|
||||
runHusky: async () => {
|
||||
ran = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(ran, false);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'preserved');
|
||||
});
|
||||
|
||||
test('a successful install leaves a complete active hook set', async () => {
|
||||
const root = await fixture('success');
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true);
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MISSING_DEPS_EXIT = 42;
|
||||
export const GENERATED_STATE_EXIT = 43;
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function entries(root) {
|
||||
const result = [];
|
||||
async function walk(current) {
|
||||
let children;
|
||||
try {
|
||||
children = await readdir(current, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
for (const child of children) {
|
||||
const target = path.join(current, child.name);
|
||||
result.push(target);
|
||||
if (child.isDirectory() && !child.isSymbolicLink()) await walk(target);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generatedSymlinkManifest(nextDir) {
|
||||
const links = [];
|
||||
for (const target of (await entries(nextDir)).sort()) {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isSymbolicLink()) continue;
|
||||
links.push({
|
||||
path: path.relative(nextDir, target).split(path.sep).join('/'),
|
||||
target: await readlink(target),
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify({ version: 1, links })}\n`;
|
||||
}
|
||||
|
||||
const webSourceRoots = (root) => [
|
||||
path.join(root, 'apps', 'web', 'src'),
|
||||
path.join(root, 'apps', 'web', 'public'),
|
||||
path.join(root, 'apps', 'web', 'next-env.d.ts'),
|
||||
path.join(root, 'apps', 'web', 'next.config.ts'),
|
||||
path.join(root, 'apps', 'web', 'postcss.config.mjs'),
|
||||
path.join(root, 'apps', 'web', 'package.json'),
|
||||
path.join(root, 'apps', 'web', 'tsconfig.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'src'),
|
||||
path.join(root, 'packages', 'design-tokens', 'package.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'tsconfig.json'),
|
||||
path.join(root, 'package.json'),
|
||||
path.join(root, 'tsconfig.base.json'),
|
||||
path.join(root, 'pnpm-lock.yaml'),
|
||||
path.join(root, 'pnpm-workspace.yaml'),
|
||||
path.join(root, 'turbo.json'),
|
||||
];
|
||||
|
||||
// next.config.ts currently reads no server-only environment. Add any future
|
||||
// server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic.
|
||||
const serverBuildEnvironmentKeys = [];
|
||||
|
||||
function publicBuildEnvironment(root) {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json'));
|
||||
const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json'));
|
||||
const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env');
|
||||
const originalEnvironment = { ...process.env };
|
||||
updateInitialEnv(originalEnvironment);
|
||||
try {
|
||||
const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true);
|
||||
return Object.fromEntries(
|
||||
Object.entries(combinedEnv).filter(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
(key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
resetEnv();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sourceFingerprint(root = process.cwd()) {
|
||||
const files = [];
|
||||
for (const sourceRoot of webSourceRoots(root)) {
|
||||
try {
|
||||
const stats = await lstat(sourceRoot);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`,
|
||||
);
|
||||
}
|
||||
if (stats.isFile()) files.push(sourceRoot);
|
||||
if (stats.isDirectory()) {
|
||||
for (const target of await entries(sourceRoot)) {
|
||||
const targetStats = await lstat(target);
|
||||
if (targetStats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, target)}`,
|
||||
);
|
||||
}
|
||||
if (targetStats.isFile()) files.push(target);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const digest = createHash('sha256');
|
||||
for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) {
|
||||
digest.update(`env:${key}\0${value.length}\0${value}\0`);
|
||||
}
|
||||
for (const target of files.sort()) {
|
||||
const contents = await readFile(target);
|
||||
digest.update(path.relative(root, target).split(path.sep).join('/'));
|
||||
digest.update('\0');
|
||||
digest.update(String(contents.length));
|
||||
digest.update('\0');
|
||||
digest.update(contents);
|
||||
digest.update('\0');
|
||||
}
|
||||
return digest.digest('hex');
|
||||
}
|
||||
|
||||
export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
const missingBinaries = [];
|
||||
for (const binary of requiredBinaries) {
|
||||
try {
|
||||
await access(path.join(binDir, binary), constants.X_OK);
|
||||
} catch {
|
||||
missingBinaries.push(binary);
|
||||
}
|
||||
}
|
||||
if (missingBinaries.length > 0) {
|
||||
return {
|
||||
code: MISSING_DEPS_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`,
|
||||
};
|
||||
}
|
||||
|
||||
const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock');
|
||||
try {
|
||||
await lstat(buildLock);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
let generated = [];
|
||||
try {
|
||||
const nextStats = await lstat(nextDir);
|
||||
if (!nextStats.isDirectory() || nextStats.isSymbolicLink()) {
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message:
|
||||
'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next must be a real directory, not a symbolic link, and is not trustworthy; run pnpm clean:generated, then rerun the gate',
|
||||
};
|
||||
}
|
||||
generated = [nextDir, ...(await entries(nextDir))];
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
if (generated.length > 0) {
|
||||
const foreign = [];
|
||||
for (const target of generated) {
|
||||
const stats = await lstat(target);
|
||||
if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target));
|
||||
}
|
||||
|
||||
// Detects accidental, independent, stale, and foreign-residue mutation of
|
||||
// generated state: the class this check was born from was a five-month-stale
|
||||
// .next whose validator referenced deleted pages and produced 19 phantom TS2307
|
||||
// errors indistinguishable from real type errors.
|
||||
//
|
||||
// Does NOT defend against an actor with same-UID write access to the generated
|
||||
// tree, which can regenerate both the manifest and marker consistently
|
||||
// (CWE-345). No local construction can, absent a trust anchor outside that
|
||||
// actor's authority. RM-59 tracks executor/spine-side attestation.
|
||||
let certification = null;
|
||||
let certifiedManifest = null;
|
||||
try {
|
||||
const [certificationContents, manifestContents] = await Promise.all([
|
||||
readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'),
|
||||
readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'),
|
||||
]);
|
||||
try {
|
||||
const parsed = JSON.parse(certificationContents);
|
||||
if (
|
||||
parsed.version === 1 &&
|
||||
typeof parsed.sourceFingerprint === 'string' &&
|
||||
typeof parsed.symlinkManifestHash === 'string'
|
||||
) {
|
||||
certification = parsed;
|
||||
certifiedManifest = manifestContents;
|
||||
}
|
||||
} catch {
|
||||
// Invalid certification is handled as untrusted generated state below.
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root));
|
||||
const actualManifest = await generatedSymlinkManifest(nextDir);
|
||||
const certifiedManifestHash =
|
||||
certifiedManifest === null
|
||||
? null
|
||||
: createHash('sha256').update(certifiedManifest).digest('hex');
|
||||
const changedSymlinks =
|
||||
certification?.symlinkManifestHash !== certifiedManifestHash ||
|
||||
certifiedManifest !== actualManifest;
|
||||
if (foreign.length > 0 || stale || changedSymlinks) {
|
||||
const reasons = [
|
||||
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
||||
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
||||
changedSymlinks
|
||||
? 'generated symbolic-link manifest does not match the certified build'
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 0, message: 'checkout preflight passed' };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await runPreflight();
|
||||
const stream = result.code === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${result.message}\n`);
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runPreflight, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);
|
||||
|
||||
const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true });
|
||||
await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n');
|
||||
return root;
|
||||
}
|
||||
|
||||
async function installRequiredBins(root) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await Promise.all(
|
||||
requiredBins.map(async (name) => {
|
||||
const target = path.join(binDir, name);
|
||||
await writeFile(target, '');
|
||||
await chmod(target, 0o755);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function certifyGeneratedState(root, links = []) {
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await mkdir(nextDir, { recursive: true });
|
||||
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
||||
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
||||
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-source-hash'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: await sourceFingerprint(root),
|
||||
symlinkManifestHash: manifestHash,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing dependencies have a dedicated exit code and install remediation', async () => {
|
||||
const root = await fixture('missing-deps');
|
||||
const result = await runPreflight({ root });
|
||||
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
|
||||
assert.match(result.message, /run pnpm install/i);
|
||||
});
|
||||
|
||||
test('a partial dependency install keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('partial-deps');
|
||||
await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('dangling-deps');
|
||||
await installRequiredBins(root);
|
||||
const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
|
||||
await rm(turbo);
|
||||
await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('installed dependencies pass when generated state is absent', async () => {
|
||||
const root = await fixture('clean');
|
||||
await installRequiredBins(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
|
||||
test('foreign-owned generated Next state is identified separately from source errors', async () => {
|
||||
const root = await fixture('foreign-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'generated output');
|
||||
|
||||
const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /foreign-owned/);
|
||||
});
|
||||
|
||||
test('a generated marker mismatch is identified separately from source errors', async () => {
|
||||
const root = await fixture('stale-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'stale generated output');
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /apps\/web\/\.next/);
|
||||
assert.match(result.message, /pnpm clean:generated/);
|
||||
});
|
||||
|
||||
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
||||
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
||||
const root = await fixture('symbolic-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
||||
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /symbolic link/);
|
||||
});
|
||||
|
||||
await t.test('apps/web/.next is rejected when it is not a directory', async () => {
|
||||
const root = await fixture('non-directory-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /real directory/);
|
||||
});
|
||||
|
||||
await t.test('an added descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-added');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a removed certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-removed');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-retargeted');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
await symlink('../dependency-two', link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-tampered-manifest');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-symlink-manifest'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
||||
const root = await fixture('symbolic-next-certified');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(
|
||||
root,
|
||||
'apps',
|
||||
'web',
|
||||
'.next',
|
||||
'standalone',
|
||||
'node_modules',
|
||||
'dependency',
|
||||
);
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../.pnpm/dependency', link);
|
||||
await certifyGeneratedState(root, [
|
||||
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), {
|
||||
code: 0,
|
||||
message: 'checkout preflight passed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
||||
const root = await fixture('inherited-typescript-config');
|
||||
const config = path.join(root, 'tsconfig.base.json');
|
||||
await writeFile(config, '{"compilerOptions":{"strict":true}}\n');
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(config, '{"compilerOptions":{"strict":false}}\n');
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('the source fingerprint rejects symbolic-link build inputs', async () => {
|
||||
const root = await fixture('symbolic-source');
|
||||
await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n');
|
||||
await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts'));
|
||||
|
||||
await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/);
|
||||
});
|
||||
|
||||
test('the source fingerprint includes expanded public web build environment', async () => {
|
||||
const root = await fixture('public-build-environment');
|
||||
const envFile = path.join(root, 'apps', 'web', '.env.production');
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('a matching generation marker accepts incremental output with mixed mtimes', async () => {
|
||||
const root = await fixture('incremental-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'unchanged generated output');
|
||||
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
||||
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
||||
await writeFile(fresh, 'fresh generated output');
|
||||
await certifyGeneratedState(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
Reference in New Issue
Block a user