Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
f4fd5967fc | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 |
@@ -8,6 +8,7 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
__pycache__/
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||||
# instead of repopulating a fresh one.
|
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||||
store-dir=/root/.local/share/pnpm/store
|
store-dir=${HOME}/.local/share/pnpm/store
|
||||||
|
|||||||
@@ -201,8 +201,21 @@ git clone [email protected]:mosaicstack/stack.git
|
|||||||
cd stack
|
cd stack
|
||||||
|
|
||||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
# 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
|
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.
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
docker compose up -d valkey
|
docker compose up -d valkey
|
||||||
|
|
||||||
@@ -230,6 +243,7 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
|||||||
### Quality Gates
|
### Quality Gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pnpm preflight # Checkout/dependency/generated-state validation
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
pnpm typecheck # TypeScript type checking (all packages)
|
||||||
pnpm lint # ESLint (all packages)
|
pnpm lint # ESLint (all packages)
|
||||||
pnpm test # Vitest (all packages)
|
pnpm test # Vitest (all packages)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the merge-base anchor round
|
|
||||||
|
|
||||||
**Subject head (exact):** `fbb61912981abb250d289ec7aafd4316db6fdb11`
|
|
||||||
**Supersedes:** the second NO-GO at `32b490a7` (D-45/D-46/D-47). That verdict is VOID.
|
|
||||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
|
||||||
|
|
||||||
## What the orchestrator already reproduced (do not re-spend the review here)
|
|
||||||
|
|
||||||
- Derived boundary `f4fd5967` **equals** an independently computed `git merge-base HEAD origin/main`.
|
|
||||||
- Emptying `criteria`/`gates`/`proseClaims`/`compatibilityScenarios` now fails with
|
|
||||||
_"population must be non-empty and anchored before evaluation"_.
|
|
||||||
- The both-directions bootstrap statement is present in `gate-history.mjs` with the Builds 1–2 residual.
|
|
||||||
|
|
||||||
## A — attack the ANCHOR (highest value)
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **A1** | `targetRef` is the literal `refs/remotes/origin/main`. **Who controls that ref in the environment where the gate runs?** Can a PR author influence it — a `remote.origin.url` rewrite, a crafted `refs/remotes/origin/main` in their own clone, a push to a fork's `main`? If the gate trusts a locally-writable ref, the anchor moved from the manifest into git config. |
|
|
||||||
| **A2** | **Absent/stale target:** if `refs/remotes/origin/main` is missing, stale, or the fetch is shallow, does it fail CLOSED (line 57 suggests it does) or silently derive a narrower range? |
|
|
||||||
| **A3** | **Delayed introduction — the round-2 attack, re-run.** Commit a gate change BEFORE adding the manifest. Does it now stay in range and fail the own-tree read? |
|
|
||||||
| **A4** | **Branch from an old main:** branch from a point far behind `origin/main`, so merge-base is old. Does the range widen correctly (safe) or include unrelated main commits that cannot carry manifests (a new false-red)? Over-inclusion is the natural failure mode of this fix. |
|
|
||||||
| **A5** | Is the **bootstrap residual** recorded as a **tracked dependency on Builds 1–2**, not prose? (D-19's third mandatory move.) |
|
|
||||||
|
|
||||||
## P — the non-empty/anchored precondition
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **P1** | Does the precondition run **before** every quantified check, on **every** path — or only the ones the author enumerated? Find a quantified check that still runs before its precondition. |
|
|
||||||
| **P2** | **Seven required gate IDs anchored to canonical sources** — can that anchor list itself be emptied, shrunk, or pointed elsewhere? An anchor list the author edits is D-45 again, one level in. |
|
|
||||||
| **P3** | The author reports finding and REMOVING a **production CLI fixture-profile bypass** in their own pre-commit review. **Verify it is gone from the shipped CLI path**, that the remaining relaxation is test-support only and non-executable in production, and that the CLI rejection is tested. This was self-disclosed — confirm it independently. |
|
|
||||||
|
|
||||||
## Q — clauses quantified over the population (D-47)
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Q1** | `gateRefs` **exactly spans** every registered gate. Can a gate be added WITHOUT a corresponding ref — i.e. does adding a gate to the population escape the clause? |
|
|
||||||
| **Q2** | Deleting `gateRefs`, or shrinking gates and refs **together**, must fail. Shrink-together is the D-46 shape; verify it is a genuine control and not a regression guard mislabelled. |
|
|
||||||
| **Q3** | Do the population controls **iterate all seven gates** and mutate evidence-subject and comparison-type **per gate**, or only a representative one? |
|
|
||||||
| **Q4** | Honest labelling: author claims delayed-introduction, empty-populations, fixture-profile bypass and removable gateRefs as **genuine red-first**, and shrink-together/exact-span as **regression guards**. Verify each claim against pre-fix code (D-8). |
|
|
||||||
|
|
||||||
## C — head, CI, integrity
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **C1** | Head `fbb61912` on git and provider; `Closes #1029` present. |
|
|
||||||
| **C2** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `verify-terminal-green.py --expect-commit fbb61912981abb250d289ec7aafd4316db6fdb11`. |
|
|
||||||
| **C3** | Nothing weakened: `32b490a7` → `fbb61912` removes/relaxes no existing case, test, or assertion. |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Verdict bound to `fbb61912981abb250d289ec7aafd4316db6fdb11`, evidence enumerated, posted durably under
|
|
||||||
your own identity. If a check is unrunnable, **say so**.
|
|
||||||
|
|
||||||
**A1 and P2 are the ones I most want answered** — both ask whether the fix moved the author's control
|
|
||||||
point again rather than removing it, which is now the mission's named first-class principle and has
|
|
||||||
recurred three times. **Attack outside this set.**
|
|
||||||
|
|
||||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the four-blocker hardening pass
|
|
||||||
|
|
||||||
**Subject head (exact):** `32b490a712a5e8e77f13c40c60099b51feb38994`
|
|
||||||
**Supersedes:** the NO-GO at `83d2ecb2` (D-44, four silent-defeat paths). That verdict is VOID.
|
|
||||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
|
||||||
|
|
||||||
## Framing — attack the FIX, not the original bug
|
|
||||||
|
|
||||||
Every round on this mission, **the remediation introduced the next hole**: the commit-binding fix
|
|
||||||
shipped a type confusion; the type-strict fix was clean but the registry around it had four defeats.
|
|
||||||
So the highest-value checks here are **not** "was each blocker fixed" — the orchestrator already
|
|
||||||
reproduced three of the four attacks as dead — but **"is the NEW mechanism itself defeatable?"**
|
|
||||||
|
|
||||||
Blocker 1's fix replaced an author-settable field with a **derivation**. A derivation has inputs. **The
|
|
||||||
question is who controls them.**
|
|
||||||
|
|
||||||
## H — the new derivation (highest value)
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **H1** | `deriveHistoryBoundary` = parent of the first first-parent commit introducing `gates/gates.manifest.json`. **Are its inputs author-controlled?** Can a PR author influence the derived value by adding, moving, renaming, deleting-and-recreating, or symlinking that path — or by adding a SECOND manifest earlier in history? If the derivation is gameable, blocker 1 is not fixed, only relocated. |
|
|
||||||
| **H2** | **First-parent traversal:** what happens on a merge commit, an octopus merge, a squash, a rebase that reorders, or a branch where the introducing commit is not on the first-parent chain? Does the boundary silently move, or fail closed? |
|
|
||||||
| **H3** | **Shallow/partial clone:** the branch previously needed an unshallow fix (`e8959975` "unshallow gate replay history"). If history is shallow, does the derivation fail CLOSED or silently derive a wrong/empty boundary? |
|
|
||||||
| **H4** | **Path deletion:** if `gates/gates.manifest.json` is absent at HEAD, or was deleted and re-added, what is derived? Fail closed, or a boundary that excludes the deletion window? |
|
|
||||||
| **H5** | Are the **three registered seam controls (HEAD, HEAD^, introduction) genuinely RED-FIRST** — do they fail against the PRE-fix code for their own stated reason? A control that was always green is a regression guard, not a must-fail control (D-8). |
|
|
||||||
|
|
||||||
## S — the recursive schema closure
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **S1** | **Is closure COMPLETE or enumerated?** The author lists the objects they closed. Find one they did **not** — any nested object anywhere in the manifest — and inject an unknown key there. If it is accepted, the fix covers instances, not the class. |
|
|
||||||
| **S2** | **Wrong-type, not just unknown-key:** `outputPattern` as a number/array/object/null rather than misspelled. Does the closed schema type-check values, or only key names? |
|
|
||||||
| **S3** | **Empty/degenerate values:** `outputPattern: ""` — does an empty regex match everything and silently neuter the assertion the same way a typo did? The author claims an empty-pattern control; verify it is bound. |
|
|
||||||
| **S4** | Confirm the registered typo/wrong-type/empty-pattern controls are **genuine red-first** against pre-fix code, and that anything labelled a regression guard is honestly labelled as one. |
|
|
||||||
|
|
||||||
## E — provider evidence (blocker 4)
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **E1** | Global validation runs **before** per-commit filtering **on every path**, including the HEAD-only path the author calls out. Is there any code path that reaches per-commit assessment without it? |
|
|
||||||
| **E2** | **Duplicate identity by another key:** the fix makes pipeline NUMBER globally unique. Can two records still collide on a different identity dimension — same commit + different number, same URL, same step-id — and certify the wrong subject? |
|
|
||||||
| **E3** | "Exactly one gate-verify step per record" — what if a record has zero, or two with different outcomes? Fail closed? |
|
|
||||||
|
|
||||||
## C — clauses, integrity, CI
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **C1** | The three new criteria (`RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`) are **checked criteria with bidirectionally bound cases**, not prose. Orchestrator observed caseRefs 1 / 3 / 3 — verify the bindings actually run and can fail. |
|
|
||||||
| **C2** | **B1/B2 satisfied in substance:** does `RM02-EVIDENCE-SUBJECT-BINDING` express D-38 generally ("does this gate bind its evidence to its subject?") or only the one pipeline-number instance? Same for D-40 vs the one typo instance. **A clause that only covers its originating instance is not a clause.** |
|
|
||||||
| **C3** | **Nothing weakened:** diff `83d2ecb2` → `32b490a7` removes/relaxes no existing case, test, or assertion. |
|
|
||||||
| **C4** | Exact-head CI terminal-green, full `-f json`, counts stated **including `clone`**; machine-checked with `verify-terminal-green.py --expect-commit 32b490a712a5e8e77f13c40c60099b51feb38994`. |
|
|
||||||
| **C5** | `Closes #1029` still present (D-38c). |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Verdict bound to `32b490a712a5e8e77f13c40c60099b51feb38994`, evidence enumerated, posted durably under
|
|
||||||
your own identity. If a check is unrunnable, **say so** — never substitute a passing variant.
|
|
||||||
|
|
||||||
**H1 and S1 are the two I most want answered**, because they ask whether each fix closed a **class** or
|
|
||||||
an **instance**. Attack outside this set: four of the last five blockers on this PR came from mutating
|
|
||||||
something nobody had registered.
|
|
||||||
|
|
||||||
**Environment note:** `pnpm gate:verify` fails on `checkout-preflight` with outcome 42 (bubblewrap
|
|
||||||
class) in at least one worktree. If you hit it, report it as an environment limitation — do not treat it
|
|
||||||
as a finding, and do not let it mask one.
|
|
||||||
|
|
||||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: honest narrowing + (d)-strict
|
|
||||||
|
|
||||||
**Subject head (exact):** `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`
|
|
||||||
**Supersedes:** the third NO-GO at `fbb61912` (D-45/D-46/D-47) and the D-52 stop. Those are VOID.
|
|
||||||
**Registered by:** `mos-remediation` before any reviewer read the diff. **Reviewer:** `rev-974`.
|
|
||||||
|
|
||||||
## Already reproduced by the orchestrator (do not re-spend here)
|
|
||||||
|
|
||||||
- The required narrowing sentence is **verbatim** in `gates/required-gates.baseline.json` and in
|
|
||||||
`scripts/gate-inventory-claim-control.mjs`: _"Detects accidental and incompetent inventory drift
|
|
||||||
within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier
|
|
||||||
consistently — that guarantee requires RM-60's external boundary."_
|
|
||||||
- **The overclaim control FIRES**: clean ⇒ exit 0; injecting "protected, independently anchored" into
|
|
||||||
the baseline purpose ⇒ **exit 84**, `INVENTORY_CLAIM_OVERSTATED`.
|
|
||||||
- CI #2201 at the exact head, 10/10 incl. `clone`; machine check bound, `exempted_steps 0`, no anomalies.
|
|
||||||
|
|
||||||
## N — the narrowing (D-52's ruling is the wording)
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **N1** | ★ Is the narrowed claim **honest EVERYWHERE**, or only where the control looks? Find any place — docs, comments, observations, manifest prose, CLI output — that still implies the baseline is protected or independently anchored. **The control only checks the files it checks.** |
|
|
||||||
| **N2** | ★ Can the **overclaim control itself** be defeated? It is PR-controlled like everything else. Weaken/skip/reword it so an overstatement passes. If it can be trivially neutered, D-52 is re-armed one level down — the exact recursion this PR keeps producing. |
|
|
||||||
| **N3** | Does the control detect **semantic** overclaim or only the literal strings it greps? An artifact could assert protection in different words and pass. |
|
|
||||||
| **N4** | Is the narrowed claim **TRUE**? Verify the baseline genuinely catches accidental/inconsistent drift — not just that it says so. A narrowed claim that is still overstated is D-48 again. |
|
|
||||||
|
|
||||||
## H — (d)-strict history removal
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **H1** | Is the verifier **structurally incapable** of reporting history-provenance-verified, or merely not currently doing so? The manifest says "structurally incapable" — test the claim. |
|
|
||||||
| **H2** | ★ **TRAP 1 re-check:** nothing emits the unverifiable state as an OBSERVATION (which exits 0). Confirm no path yields exit 0 while implying provenance was considered. |
|
|
||||||
| **H3** | ★ **TRAP 2 re-check:** the exclusion is **enforced**, not merely declared in `coverageBoundary.excluded`. Re-enable history provenance without a trustworthy anchor — does a registered case go RED? |
|
|
||||||
| **H4** | The residual is stated **correctly** — no local git state is trustworthy because PR code executes before the gate — **NOT** the D-48 wording ("compromised main"). |
|
|
||||||
|
|
||||||
## B3 / R — evidence-subject and renderer
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **B1** | Subject is on the **EVIDENCE side** and compared **at consumption**, not `gate.evidenceSubject === gate.id` metadata-vs-itself. Mutate the evidence-side subject **per gate**. |
|
|
||||||
| **B2** | Can evidence still be moved or misbound while every gate satisfies its check? |
|
|
||||||
| **R1** | Renderer now covers the **final success stdout/stderr** paths. |
|
|
||||||
|
|
||||||
## C — integrity + CI
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **C1** | Head `e910a45a` on git + provider; `Closes #1029` present. |
|
|
||||||
| **C2** | Exact-head CI terminal-green, `-f json`, counts incl. `clone`; machine-checked with `--expect-commit e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`. |
|
|
||||||
| **C3** | Nothing weakened `fbb61912` → `e910a45a`. Red-first labels honest (D-8). |
|
|
||||||
| **C4** | **NO fourth local anchor was invented.** Blocker 1 and blocker 2's adversarial guarantee both track RM-60. |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Verdict bound to `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`, evidence enumerated, posted durably.
|
|
||||||
Unrunnable ⇒ **say so**. **N1 and N2 are the sharp ones** — this PR's history is that every fix
|
|
||||||
relocates the defect one level down, and N2 asks whether the control that enforces honesty is itself
|
|
||||||
honest. **Attack outside the set: six for six so far.**
|
|
||||||
|
|
||||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# RM-02 / PR #1030 — PRE-REGISTERED ACCEPTANCE CHECKS (post-rebase, keystone)
|
|
||||||
|
|
||||||
**Subject head (exact):** `83d2ecb2243f1b0987ef2bc14de47f444285a684`
|
|
||||||
**Rebased onto:** `main` @ `f4fd5967fc5d4cbc72d680b199d88224aa855131` (post-RM-61)
|
|
||||||
**Registered by:** `mos-remediation` **BEFORE the reviewer read the diff.** **Reviewer:** `rev-974`
|
|
||||||
(author ≠ reviewer; branch commits are `f10-coder`'s and `coder-mos1`'s).
|
|
||||||
**Prior reviews 63 / 65 are SUPERSEDED; this head carries NO live review of any kind.**
|
|
||||||
|
|
||||||
RM-02 is the **keystone**: it is the anti-inert-gate registry every downstream gate is measured against.
|
|
||||||
A defect that survives here propagates into every gate this mission ships.
|
|
||||||
|
|
||||||
## ★ CRUX — the one non-mechanical change, made by the author, that turned a red green
|
|
||||||
|
|
||||||
The rebase was clean. One further commit was **not** purely mechanical and the author disclosed it
|
|
||||||
unprompted (`83d2ecb2` — advance registry activation seam):
|
|
||||||
|
|
||||||
gates/gates.manifest.json
|
|
||||||
- "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c"
|
|
||||||
+ "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131"
|
|
||||||
|
|
||||||
Stated rationale: after the rebase, `pnpm gate:verify` correctly failed because the newly-merged
|
|
||||||
pre-registry RM-61 commit was treated as _prospective_ and required a manifest that could not exist.
|
|
||||||
|
|
||||||
`activationCommit` sets the lower bound of `activationCommit..HEAD` — **the set of commits required to
|
|
||||||
carry own-tree registry provenance** (`gate-history.mjs:314` → `listProspectiveCommits`). Advancing it
|
|
||||||
**shrinks that set**.
|
|
||||||
|
|
||||||
**Determine, do not assume — and I have deliberately formed and stated no verdict (D-39):**
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **A1** | **Is the advance JUSTIFIED?** Can a pre-registry commit on `main` (e.g. `f4fd5967`) satisfy the provenance requirement at all, or is requiring it impossible-by-construction? If impossible, is advancing the seam the _correct_ remedy, or does a correct remedy exist that does not move an author-controlled field? |
|
|
||||||
| **A2** | **Is the advance MINIMAL?** Was it advanced exactly to the new parent baseline, or beyond it? Does any commit that _should_ remain covered fall outside the new range? |
|
|
||||||
| **A3** | **Are all 7 branch commits still PROSPECTIVE under the new seam?** Enumerate them and show each is still required to carry provenance. The author claims "own-tree provenance over every registry-era branch commit" — verify the claim, do not accept it. |
|
|
||||||
| **A4** | ★★ **IS THE SEAM ITSELF CONSTRAINED?** The only validation found is `merge-base --is-ancestor activationCommit head` (`gate-history.mjs:288`). **A commit is its own ancestor.** Determine what `activationCommit = HEAD` does: is the prospective range empty, does the per-commit loop iterate zero times, and does the history check therefore PASS VACUOUSLY? If so, the anti-inert-gate registry contains an author-settable field that can inert its own history check. **Run it. Report the observed exit and whether any failure is raised.** |
|
|
||||||
| **A5** | **If A4 shows vacuity is reachable, is there a registered MUST-FAIL negative control for it?** RM-02's own founding rule is that a gate with no proven failure path manufactures evidence. Does the registry hold a case that goes RED when the seam is over-advanced? If not, that is a hole in the registry's coverage of itself. |
|
|
||||||
| **A6** | **Self-verification shape (charter / D-19 / D-39b):** the field was advanced by the change's own author to make the author's gate pass. Independent of whether the value is correct, determine whether the _mechanism_ permits an audited party to relax its own audit, and whether that needs a constraint, an owner, or an escalation. |
|
|
||||||
|
|
||||||
## Registry substance — the clauses this PR must now carry
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **B1** | **D-38 clause present and enforced:** the registry asks of every gate _"does this gate BIND its evidence to the subject under review?"_ Ruled in-scope for THIS PR by Mos. Verify it exists as a checked clause, not prose. |
|
|
||||||
| **B2** | **D-40 clause present and enforced:** _"discriminator and comparison inputs must be TYPE-STRICT."_ Also ruled in-scope for THIS PR. `== 0` matching `False`/`0.0` is the banked instance; the clause is the general form. |
|
|
||||||
| **B3** | **D-42's clause is CORRECTLY ABSENT** — provider-side negative control tracks separately (Mos: landing it now would give the registry a clause with nothing to satisfy it, going red on its own clause). Confirm its absence is deliberate and recorded, not forgotten. |
|
|
||||||
| **B4** | **The four founding clauses still hold** (`MISSION.md`): every check proven **right** (red for its own stated reason), the set **covers**, no two criteria **conflict**, and criterion evolution **retains original text + restatement + reason**. |
|
|
||||||
| **B5** | **Nothing was weakened, skipped, or deleted by the rebase.** Diff `f9746b23` → `83d2ecb2` and confirm no registered case, test, or assertion was removed or relaxed to make the rebase land. |
|
|
||||||
| **B6** | **`coverageBoundary.excluded[]` is honest in BOTH directions** (charter principle 4): what it does NOT cover is stated beside what it DOES, and the gap is tracked (`trackedBy: RM-54`) rather than implied-fixed. |
|
|
||||||
| **B7** | **The RM-02 execution boundary** (`gate-history.mjs`, DOES / DOES NOT, owner RM-60, xref RM-59) states its limits in both directions and names a tracked owner — not a documented gap with no owner. |
|
|
||||||
|
|
||||||
## Head + CI (re-run these; do not carry them forward from the author's report)
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **C1** | Head `83d2ecb2…` on **both** git and provider; `Closes #1029` present in the body (delivery-issue rule, D-38c). |
|
|
||||||
| **C2** | Exact-head CI terminal-green by **full `-f json` scan**, counts stated **including `clone`**. Note the count changed 9 → **10** (this PR adds `gate-verify`) — confirm the new step is real, not a miscount. |
|
|
||||||
| **C3** | **Machine-check it, do not assert it:** `verify-terminal-green.py --expect-commit 83d2ecb2243f1b0987ef2bc14de47f444285a684`. Report exit, `exempted_steps`, `commit == expected_commit`. |
|
|
||||||
| **C4** | `exempted_steps=0` is expected here — `ci-postgres` succeeded, so the #1000 exemption was **not needed**. Confirm the exemption is present-but-unused rather than silently inapplicable. |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Verdict bound to `83d2ecb2243f1b0987ef2bc14de47f444285a684`, evidence enumerated per check, posted
|
|
||||||
durably under your own identity. If a check is unrunnable, **say so** — never substitute a variant that
|
|
||||||
passes. Scan CI from `-f json`, never default text (D-33); state your counts.
|
|
||||||
|
|
||||||
**A4 is the check I most want an answer to and the one I have least confidence about.** Attack outside
|
|
||||||
this set as well: every blocker found on this mission so far came from mutating something nobody had
|
|
||||||
registered a check for.
|
|
||||||
|
|
||||||
**No one dispatching this review may state a conclusion on an open check (D-39).** Observations may be
|
|
||||||
relayed to you; verdicts may not. The ruling is yours.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# RM-03 / PR #1032 — PRE-REGISTERED RE-REVIEW (post-freshening)
|
|
||||||
|
|
||||||
**Subject head (exact):** `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`
|
|
||||||
**Rebased onto:** `main` @ `f4fd5967` **Registered by:** `mos-remediation` before the reviewer read the
|
|
||||||
diff. **Reviewer:** `rev-974`.
|
|
||||||
|
|
||||||
**Why this exists:** review 66 APPROVED and the merge-gate GO were bound to `78ec47cd`; `main` drifted
|
|
||||||
under a long hold and the PR went `mergeable=false` (**D-50 — a GO has a shelf life**). Both verdicts
|
|
||||||
are VOID at the new head. Jason's merge approval is **standing** and executes once this re-gates.
|
|
||||||
|
|
||||||
## What the orchestrator already verified (do not re-spend here)
|
|
||||||
|
|
||||||
- `git range-diff 78ec47cd...868b9b87`: commits **2–5 are `=` (patch-equivalent)**; only commit 1 differs.
|
|
||||||
- That single difference is the `test:framework-shell` chain, resolved as a **UNION** — main's
|
|
||||||
`test-terminal-green-contract.sh` **and** RM-03's `tristate`, `github-checks`, `merge-queue-branch`,
|
|
||||||
`merge-head-pin`, plus the pre-existing `branch-absent`. **Nothing dropped from either side.**
|
|
||||||
- CI #2199 at the exact head: 9 children, 9 success incl. `clone`; machine check exit 0, bound, no anomalies.
|
|
||||||
- `mergeable` is now **true**.
|
|
||||||
|
|
||||||
## Checks
|
|
||||||
|
|
||||||
| id | check |
|
|
||||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **R1** | ★ **THE GUARD CAN FAIL.** RM-03 exists because the queue guard is ZERO-INFORMATION (D-23) — it returns pass for every possible input. Prove the rebased head's guard **actually fails** on asserted non-readiness, not merely that its tests pass. This is the whole deliverable. |
|
|
||||||
| **R2** | ★ **NO QUEUE-GUARD SEMANTIC MOVED IN THE REBASE.** Confirm the range-diff equivalence independently. A queue-guard semantic resolved quietly re-breaks the thing this PR fixes. |
|
|
||||||
| **R3** | **Union is complete in BOTH directions:** no RM-03 suite lost to main's version, no main suite (esp. `test-terminal-green-contract.sh`) lost to RM-03's. Enumeration count reconciles. |
|
|
||||||
| **R4** | **Tri-state is real** (`verified` / `written-unverified` / `failed`) — P-WRAPPER-001. Verify an indeterminate result cannot present as a pass; that is the exact defect (`state=unknown exit 0`). |
|
|
||||||
| **R5** | The guard's **exit-asserting non-null-case** tests are genuine — each fails for its own stated reason against pre-fix code, not merely present (D-8). |
|
|
||||||
| **R6** | `test:framework-shell` is **runnable to completion in CI** (canonical env). Known: it exits 97 on some hosts via a Bash 5.2.15 `BASH_LINENO` assertion — if you hit that, report it as an environment limitation, do not treat it as a finding and do not let it mask one. |
|
|
||||||
| **R7** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `--expect-commit 868b9b871a5118762aa5b8aafecd2f0592f7ab5c`. |
|
|
||||||
| **R8** | `Closes #1019` present in the body (D-38c delivery-issue rule). |
|
|
||||||
| **R9** | Nothing weakened by the rebase: no test, case, or assertion removed or relaxed to make it land. |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Verdict bound to `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`, evidence enumerated, posted durably under
|
|
||||||
your own identity. If a check is unrunnable, **say so**.
|
|
||||||
|
|
||||||
**R1 is the deliverable and R2 is the risk.** Attack outside this set. **Do not cite the queue guard's
|
|
||||||
own green as evidence of anything** — it remains inert until this very PR lands (D-23).
|
|
||||||
|
|
||||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# RM-61 / PR #1033 — PRE-REGISTERED RE-REVIEW ACCEPTANCE CHECKS
|
|
||||||
|
|
||||||
**Subject head (exact):** `033b2ffb46674b2c0bcc5197273c109b461f62d9`
|
|
||||||
**Supersedes:** review 67 / comment 20403 @ `e7b29219` (NO GO). That verdict is VOID — the head moved.
|
|
||||||
**Registered by:** `mos-remediation` (orchestrator). **Reviewer:** `rev-974` (author ≠ reviewer).
|
|
||||||
**Registered BEFORE the reviewer read the diff.** Any head move after this file is committed voids the
|
|
||||||
re-review and requires re-registration.
|
|
||||||
|
|
||||||
## Scope discipline
|
|
||||||
|
|
||||||
The prior review passed AC1–AC8 and still found a blocker, because the registered set tested whether the
|
|
||||||
signature DISCRIMINATES and never tested whether the evidence was BOUND TO ITS SUBJECT (Finding 3 /
|
|
||||||
coverage-failure-mode-2, D-17 class). This set therefore carries the binding property as a first-class
|
|
||||||
check, and **RR7 explicitly invites the reviewer to attack outside the set** — a registered set is
|
|
||||||
protection against retrofitting only, never a ceiling on scrutiny.
|
|
||||||
|
|
||||||
## Checks
|
|
||||||
|
|
||||||
| id | check | verdict form |
|
|
||||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
|
||||||
| **RR1** | The full 12-case contract harness passes at the exact head: `bash test-terminal-green-contract.sh` | ⇒0, and states 12 cases |
|
|
||||||
| **RR2** | ★CRUX — the ORIGINAL ATTACK IS DEAD. Take the real `#2188` record, mutate ONLY `commit` to an unrelated 40-hex value, verify against the true expected head | ⇒1, `exempted_steps == 0`, anomaly naming expected vs actual |
|
|
||||||
| **RR3** | Missing `--expect-commit` cannot be defaulted, inferred, or skipped | ⇒2 (not 0, not 1) |
|
|
||||||
| **RR4** | Malformed expected commit (short SHA, non-hex, empty) is rejected — no silent normalisation into a pass | ⇒2 |
|
|
||||||
| **RR5** | A record with the `commit` key ABSENT (not merely different) is rejected — fail-closed on missing, not just on mismatch | ⇒1, `exempted_steps == 0` |
|
|
||||||
| **RR6** | The genuine artifact still passes when correctly bound: real `#2188` + its true head | ⇒0, `exempted_steps == 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
|
||||||
| **RR7** | ★RED-FIRST, PROVED RETROACTIVELY. The four new cases must FAIL against the OLD verifier at `e7b29219` — otherwise they do not test what they claim (D-8 class). Run the new cases against the previous implementation | new cases ⇒≠0 under `e7b29219` |
|
|
||||||
| **RR8** | AC2 OF THE PRIOR SET DID NOT REGRESS: both REAL controls stay terminal red — `#2189` (`ci-postgres` exit 1) and `#2191` (exit 137, `test` exit 61) | both ⇒1, `exempted_steps == 0` |
|
|
||||||
| **RR9** | Still NO fetch / trigger / retry / re-roll / sleep / network of any kind in the verifier or harness. The coin flip must remain removed, not codified (D-21) | grep ⇒ no such call sites |
|
|
||||||
| **RR10** | The doc/baseline changes REQUIRE the current provider PR head to be passed — they must not merely mention it. Check `merge-gate.md`, `CI-CD-PIPELINES.md`, `woodpecker/README.md` state it as a requirement a gate operator cannot satisfy by omission | reviewer judgement, quote the lines |
|
|
||||||
| **RR11** | Exemption remains bound to #1000 and retires with it; signature conjunction unchanged and not widened by this fix | diff-scoped, ⇒ no widening |
|
|
||||||
| **RR12** | Case-sensitivity: an uppercase-hex record commit against a lowercase expected head must NOT silently pass by accident of comparison. State which way it resolves and whether it fails closed | state the observed behaviour |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Report the verdict **bound to `033b2ffb46674b2c0bcc5197273c109b461f62d9`** and state each check's
|
|
||||||
observed result, including counts read from `pipeline-status.sh -f json` (never default text — it omits
|
|
||||||
`clone`, D-33). If any check is unrunnable, **say so** — never substitute a passing variant. Attack
|
|
||||||
outside this set and report anything it finds; RR7 and RR12 exist because the last blocker was found
|
|
||||||
exactly that way.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# RM-61 / PR #1033 — PRE-REGISTERED ACCEPTANCE CHECKS: `exit_code` TYPE-STRICTNESS (D-40)
|
|
||||||
|
|
||||||
**Registered by:** `mos-remediation` (orchestrator) — **BEFORE the fix was pushed and before any
|
|
||||||
reviewer read a diff.** At registration time the fix existed only as an unpushed local commit
|
|
||||||
(`6e7a336d`) on coder-mos1's machine which **I have not read**. There is therefore no diff for these
|
|
||||||
checks to have been retrofitted to.
|
|
||||||
|
|
||||||
**Subject head:** TBD — binds to the NEW head once coder-mos1 pushes. The prior head
|
|
||||||
`033b2ffb46674b2c0bcc5197273c109b461f62d9` and its review 69 / pipeline #2193 go VOID on that push;
|
|
||||||
that cost was accepted deliberately by Mos's BLOCKING ruling on D-40.
|
|
||||||
|
|
||||||
**Ruling being enforced (Mos, 2026-08-01):** `exit_code` must be accepted ONLY as a real integer.
|
|
||||||
`false`, `0.0`, `"0"`, and `null` must all fail to satisfy the exemption. Wrong-ACCEPT is the
|
|
||||||
disqualifying direction; this hole sits inside the load-bearing discriminator.
|
|
||||||
|
|
||||||
## ⚠ Read this before writing the tests — RED-FIRST HERE IS NOT UNIFORM
|
|
||||||
|
|
||||||
Two of the four near-miss values **already block** at `033b2ffb`. Demanding "all four observed RED
|
|
||||||
first" would be demanding an impossible red for two of them, and the predictable response to an
|
|
||||||
impossible demand is a fudge — weakening something real to manufacture the red. So state it precisely:
|
|
||||||
|
|
||||||
| value | behaviour at `033b2ffb` (pre-fix) | what the new case is |
|
|
||||||
| ------- | --------------------------------- | ------------------------- |
|
|
||||||
| `false` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
|
||||||
| `0.0` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
|
||||||
| `"0"` | correctly blocks (exit 1) | **regression guard** only |
|
|
||||||
| `null` | correctly blocks (exit 1) | **regression guard** only |
|
|
||||||
|
|
||||||
Claiming red-first for `"0"` or `null` would be a false claim about your own evidence. Say which is
|
|
||||||
which. **Do not weaken anything to make a green case go red** (D-8).
|
|
||||||
|
|
||||||
## Checks
|
|
||||||
|
|
||||||
| id | check | verdict form |
|
|
||||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| **TS1** | ★RED-FIRST. `exit_code: false` on the real #2188 record is **observed wrongly exempting at `033b2ffb`** (exit 0, `exempted_steps 1`), then blocks after the fix | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
|
||||||
| **TS2** | ★RED-FIRST. Same for `exit_code: 0.0` | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
|
||||||
| **TS3** | REGRESSION GUARD. `exit_code: "0"` blocked before AND after — state honestly that it was already green | both ⇒1, `exempted_steps 0` |
|
|
||||||
| **TS4** | REGRESSION GUARD. `exit_code: null` blocked before AND after | both ⇒1, `exempted_steps 0` |
|
|
||||||
| **TS5** | ★NOT OVER-TIGHTENED. The genuine artifact — real #2188, real integer `exit_code: 0`, correctly bound head — still passes | ⇒0, `exempted_steps 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
|
||||||
| **TS6** | The strictness sits on the value the EXEMPTION rests on, not on a cosmetic sibling. Show the guarded comparison is the one feeding `exemption_applies` | reviewer quotes the line |
|
|
||||||
| **TS7** | `bool` is excluded EXPLICITLY, not incidentally. A bare `isinstance(x, int)` still admits `True`/`False` — verify the `not isinstance(x, bool)` clause exists | ⇒ clause present; `true` also blocks |
|
|
||||||
| **TS8** | Negative control unaffected: a genuine failed step with a real integer non-zero exit still blocks | ⇒1, `exempted_steps 0` |
|
|
||||||
| **TS9** | NO REGRESSION ON THE PRIOR ROUND'S BINDING WORK: mutated record commit ⇒1; `--expect-commit` omitted ⇒2; record `commit` absent ⇒1 | ⇒1 / ⇒2 / ⇒1 |
|
|
||||||
| **TS10** | Signature conjunction NOT widened elsewhere by this fix — `POD_NOT_FOUND` / name / type / state untouched | diff-scoped ⇒ no widening |
|
|
||||||
| **TS11** | Still no fetch / trigger / retry / re-roll / sleep / network | grep ⇒ no call sites |
|
|
||||||
| **TS12** | Full harness passes at the new head; **state the case count** (12 previously, expected 16 — confirm the actual number rather than the expected one) | ⇒0, count stated |
|
|
||||||
|
|
||||||
## Reviewer instruction
|
|
||||||
|
|
||||||
Verdict bound to the NEW head, evidence enumerated per check, CI counts from `pipeline-status.sh -f json`
|
|
||||||
(never default text — it omits `clone`, D-33). If a check is unrunnable, **say so**; never substitute a
|
|
||||||
passing variant.
|
|
||||||
|
|
||||||
**Attack outside this set and report what you find.** Both blockers on this PR so far — the missing
|
|
||||||
commit binding, and this type confusion — were found outside the registered set, by mutating a field
|
|
||||||
nobody had registered a check for. That is now the expectation, not a bonus.
|
|
||||||
|
|
||||||
**Nobody dispatching this review may state a conclusion on an open check here (D-39).** If you are sent
|
|
||||||
an observation, it is an observation; the ruling is yours.
|
|
||||||
@@ -23,68 +23,3 @@ 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.**
|
### **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.
|
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.
|
|
||||||
|
|
||||||
<!-- board-roll: Decisions-log narrative rolled from BOARD.md 2026-08-01 (D-43: meet the
|
|
||||||
byte budget by ROLLING OUT, never by rewording what stays) -->
|
|
||||||
|
|
||||||
### Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
|
||||||
|
|
||||||
All 44 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-43 + D-38c in `TASKS.md`) and every ruling with its
|
|
||||||
rationale live there. **Not duplicated here** — a second copy is a second thing to go stale, which this
|
|
||||||
board had done three times in one night (gate list, capability registry, DECISION-1 status), and three
|
|
||||||
more times by the next rotation seam (RM-61 "building", "nothing implemented yet", DECISION-1/2/3
|
|
||||||
"must be ruled"). The rulings a fresh seat needs are items 4–7 above; they are **not** repeated here,
|
|
||||||
because that repetition is what went stale.
|
|
||||||
|
|
||||||
<!-- board-roll: Sequencing section rolled verbatim from BOARD.md 2026-08-01 (D-43: meet the
|
|
||||||
byte budget by ROLLING OUT, never by rewording what stays). Canonical: MISSION.md + TASKS.md §5 -->
|
|
||||||
|
|
||||||
### Sequencing — see [`MISSION.md`](./MISSION.md)
|
|
||||||
|
|
||||||
Builds 1-5, the cross-cutting retirements, and DECISION-1's corrected wire-in target are stated once in
|
|
||||||
the charter and `TASKS.md` §5. **Not repeated here** — the previous copy of DECISION-1's status on this
|
|
||||||
board is one of the six stale restatements below.
|
|
||||||
|
|
||||||
<!-- board-roll: capability/seat-identity bullets rolled verbatim from BOARD.md 2026-08-01
|
|
||||||
(D-43: roll out, never reword). Authoritative home: TASKS.md D-11 / D-11a / D-11b. -->
|
|
||||||
|
|
||||||
- Capability is **per-path** (D-11b → **superseded in part by D-13/D-15**): a token file is **necessary,
|
|
||||||
not sufficient**. Three layers — token file (raw-API), `tea` login (tea paths), **repository permission**
|
|
||||||
(writes). Before dispatch, assert `permissions.push == true` **as that seat**, not token existence and
|
|
||||||
not a 200 on a read. Mos owns provisioning; escalate missing pairs.
|
|
||||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
|
||||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
|
||||||
|
|
||||||
<!-- board-roll: D-26 gate-restatement rationale rolled verbatim from BOARD.md 2026-08-01 -->
|
|
||||||
|
|
||||||
### Why the board must not restate the delivery gates (D-26)
|
|
||||||
|
|
||||||
Restating the gate from memory is how the merge-gate step went missing from mission setup twice,
|
|
||||||
once inside the correction for it (**D-26**).
|
|
||||||
|
|
||||||
<!-- board-roll: Fleet seats rolled verbatim from BOARD.md 2026-08-01 (D-43: roll out, never
|
|
||||||
reword). NOTE: all these seats are UNMANAGED per D-41; `mosaic fleet ps` is live truth. -->
|
|
||||||
|
|
||||||
## Fleet seats
|
|
||||||
|
|
||||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
|
||||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
|
||||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
|
||||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
|
||||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
|
||||||
|
|||||||
+73
-63
@@ -1,83 +1,93 @@
|
|||||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||||
|
|
||||||
**Phase:** EXECUTING — RM-03 at owner-merge; RM-61 **MERGED**; **RM-02 keystone is the front**.
|
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||||
**Updated:** 2026-08-01 — seam crossed; successor seat resumed, attested from the files, and is driving.
|
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||||
⚠ That was a **MANUAL pane respawn** (prior seat ~803k tokens): it validates the checkpoint+rehydration
|
|
||||||
**design**, NOT a lifecycle **mechanism** — P-LIFECYCLE rotation does not exist yet (**D-41 / RM-62**).
|
|
||||||
|
|
||||||
## Head
|
## Head
|
||||||
|
|
||||||
- Charter + 15 decisions + 4-build plan: `MISSION.md`. Backlog + all findings: `TASKS.md`.
|
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||||
- Planning DONE (58 tasks, P0–P5). **DECISION-1/2/3 all RULED by Mos 2026-07-31** (`TASKS.md` §5) —
|
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||||
nothing is waiting on a decision. D-2's availability _target_ is Jason-pending and non-blocking.
|
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||||
- **Executing, not planning.** RM-01 is MERGED; three lanes are live (see In-flight).
|
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
||||||
- Orchestrator seat `mos-remediation` LIVE, owns the mission, resumed across the rotation seam
|
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
||||||
2026-08-01 and re-attested to Mos from the files. Residency attestation: PASS.
|
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
||||||
|
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
||||||
|
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
||||||
|
|
||||||
## In-flight
|
## In-flight
|
||||||
|
|
||||||
| Task | Owner | State |
|
| Task | Owner | State |
|
||||||
| ------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||||
| RM-01 checkout | — | **MERGED** `f58b3699` (#1027) |
|
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||||
| RM-03 queue guard | Jason (re-sync) | ✅ **MERGED** `58b971ab` (#1032), #1019 closed. ⚠ **NOT DELIVERED**: installed guard still the broken one (291 lines / 0 `ASSERTED_NOT_READY` vs main 482 / 5). Re-sync via `mosaic upgrade`, then **prove it blocks a KNOWN-RED pipeline** — **D-51** |
|
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||||
| RM-02 registry ★key | rev-974 | **RE-REVIEW @ `e910a45a`** — (d)-strict history REMOVED + blocker 2 NARROWED (D-52) + blocker 3 + renderer. Overclaim control **fires at exit 84**, verified by orchestrator. CI 2201 **10/10**. ACs @ `dde38717` |
|
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||||
| RM-61 CI exemption | — | ✅ **MERGED** `f4fd5967` (#1033). #1034 closed; **#1000 stays OPEN** (retirement trigger). Exemption is on `main` |
|
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||||
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
|
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||||
| #1023 queue attempt | Jason | SUPERSEDED-PENDING-JASON — live REQUEST_CHANGES, do **not** merge |
|
|
||||||
|
|
||||||
### For the incoming orchestrator — read this before acting
|
|
||||||
|
|
||||||
1. **Lane state lives in the In-flight table above — this item does NOT restate it.** It went stale
|
|
||||||
three times in one session by duplicating that table (D-26's class). Read the table. ⚠ **And
|
|
||||||
re-derive any board claim from the provider before load-bearing use (D-43)** — the board is
|
|
||||||
sole-written and has no independent verifier.
|
|
||||||
2. **`docs/remediation/TASKS.md` is authoritative**, not the newest voice in a chat. It holds 53 findings
|
|
||||||
(D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-52 + D-38c in TASKS.md), every ruling with its rationale, and the
|
|
||||||
requirements each finding placed on RM-02/RM-34/RM-50/RM-55.
|
|
||||||
3. **`MISSION.md` carries the first-class principles** — read them there, they are not listed here.
|
|
||||||
Two added 2026-08-01: **the anchor must live outside the audited party's authority** (D-19/D-25/D-45,
|
|
||||||
third arrival) and **no universally-quantified check may pass over an empty set** (D-44/D-46).
|
|
||||||
4. **Seat identity:** `export MOSAIC_GIT_IDENTITY=<seat>` is stripped by a context reset (**D-34**) —
|
|
||||||
every dispatch/rehydration brief must re-export it, or the seat cannot use its credentials.
|
|
||||||
5. **Scan CI from `-f json`, never default text** — text mode omits `clone` (**D-33**). State counts.
|
|
||||||
6. **The queue guard is zero-information until RM-03 merges** (**D-23**) — never cite its green.
|
|
||||||
7. **The bounded CI re-roll used on RM-02 was a one-time stopgap, NOT policy.** A per-PR free re-roll is
|
|
||||||
D-21 normalisation. Do not repeat it; RM-61 is the fix.
|
|
||||||
|
|
||||||
## Delivery gates — REFERENCE, do not restate
|
|
||||||
|
|
||||||
Canonical: `~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) +
|
|
||||||
`~/.config/mosaic/fleet/roles/validator.md`. Order and the freeze/zero-information rules: `MISSION.md`
|
|
||||||
and `KICKSTART.md`. **Read them there** (why: **D-26**, in `BOARD-LEDGER.md`).
|
|
||||||
|
|
||||||
## Fleet seats
|
## Fleet seats
|
||||||
|
|
||||||
Roster rolled verbatim to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md); live truth is `mosaic fleet ps`.
|
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||||
|
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||||
|
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||||
|
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||||
|
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||||
|
|
||||||
## Gate status
|
## Gate status
|
||||||
|
|
||||||
|
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||||
- Freeze: LIFTED for this workstream only.
|
- Freeze: LIFTED for this workstream only.
|
||||||
- Git identity: orchestrator runs `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||||
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||||
- Capability + seat identity (**D-11b / D-11a**, incl. the false-NEGATIVE twin): authoritative in
|
- Capability check (D-11b): before dispatching seat X to provider Y, verify
|
||||||
`TASKS.md`. Short form — **assert the DIFFERENTIAL as that seat** (authenticated `push:true` vs
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
|
||||||
unauthenticated `push:false`); a single endpoint can be true for anyone or 403 for an unrelated
|
capability registry. Mos owns provisioning; escalate missing pairs to him.
|
||||||
scope. Full text rolled to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||||
- ⚠ **LIVE HAZARD (D-37) — one shared `.git/config` re-identifies EVERY worktree at once.** Every seat,
|
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||||
including `rev-974`'s review worktree, currently authors as **`coder-mos1`**; `MOSAIC_GIT_IDENTITY`
|
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
|
||||||
does **not** override it. **STANDING ORDER: commit with explicit
|
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
|
||||||
`git -c user.name=<seat> -c user.email=<seat>@…`, and NOBODY rewrites the shared config mid-flight.**
|
`-m`; heavy artifacts off shared `/tmp`.
|
||||||
Real fix authorised, Mos owns it, sequenced at a quiet seam. **#1024 implicated.** Detail: D-37.
|
|
||||||
- Standing worker-brief doctrine (mandatory in EVERY brief): re-export `MOSAIC_GIT_IDENTITY` (**D-34**);
|
|
||||||
commit early/WIP (**D-31**); don't weaken a RED test to pass; if a check is unrunnable SAY SO, never
|
|
||||||
substitute; `agent-send -f` never `-m`; artifacts off shared `/tmp`; scan CI from `-f json` (**D-33**);
|
|
||||||
**relay observations into an open review, NEVER your own conclusion on an open check (D-39)**; the
|
|
||||||
**author never adjudicates their own PR's blocker status** — surface evidence, prepare the fix, hold.
|
|
||||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||||
|
|
||||||
## Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
## Sequencing (from MISSION.md)
|
||||||
|
|
||||||
All 53 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-52 + D-38c in `TASKS.md`) and every ruling with
|
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||||
its rationale live there. **Not duplicated here.** The history of _why_ this board must not restate —
|
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
||||||
six stale copies across two seams — is rolled verbatim into [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
||||||
|
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
||||||
|
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||||
|
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||||
|
4. Hygiene + conformance harness
|
||||||
|
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||||
|
|
||||||
|
## Dogfood evidence — live failure classes, not hypotheticals
|
||||||
|
|
||||||
|
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
||||||
|
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
|
||||||
|
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||||
|
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||||
|
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||||
|
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||||
|
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||||
|
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||||
|
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||||
|
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||||
|
|||||||
@@ -25,18 +25,8 @@ mechanically until Build 3 (rotation) makes it automatic.
|
|||||||
## Standing invariants (never violate)
|
## Standing invariants (never violate)
|
||||||
|
|
||||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||||
- **Delivery gates — REFERENCE the canonical files, never restate them:**
|
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
|
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||||
`~/.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).
|
- **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.
|
- **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.
|
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||||
|
|||||||
+2
-143
@@ -91,99 +91,6 @@ 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
|
> 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.
|
> 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 — the anchor must live outside the audited party's authority
|
|
||||||
|
|
||||||
> **You cannot fix "the author controls X" by deriving X from something the author ALSO controls.**
|
|
||||||
> Deriving merely **moves** the control point; it does not remove it.
|
|
||||||
>
|
|
||||||
> Promoted to the charter by Mos (2026-08-01) on the **THIRD INDEPENDENT ARRIVAL** of the same
|
|
||||||
> conclusion, each reached while trying to ship something else, each from a different direction:
|
|
||||||
>
|
|
||||||
> | arrival | the audited party controls… | found as |
|
|
||||||
> | ----------------- | ------------------------------------------------------------------- | -------- |
|
|
||||||
> | manifest | the tree that certifies its own generated state (same-UID, CWE-345) | **D-19** |
|
|
||||||
> | sandbox | the code that enters the sandbox, before the boundary exists | **D-25** |
|
|
||||||
> | **registry seam** | **WHEN the tracked path is introduced, hence the derived boundary** | **D-45** |
|
|
||||||
>
|
|
||||||
> Round 1 the value was an author-settable **field**, so it was **derived**. Round 2 the derivation
|
|
||||||
> depended on **when the author introduces the path**. Same authority, new costume. **Three
|
|
||||||
> impossibility-derivations of one conclusion is not a coincidence to note — it is the strongest
|
|
||||||
> architectural evidence this mission has produced**, and it is precisely what **forces Builds 1–2**
|
|
||||||
> rather than making them a preference.
|
|
||||||
>
|
|
||||||
> **Operationally:** anchor to a reference the audited party cannot move. A git **merge-base against
|
|
||||||
> `main`** is such a reference for a PR author (they own their branch; they do not own `main`).
|
|
||||||
> **State the bootstrap in BOTH directions (D-19):** that anchor is sound against an author who cannot
|
|
||||||
> rewrite `main` — the threat in scope — and **NOT** sound against an attacker who can. **That residual
|
|
||||||
> is what Builds 1–2 close, and it must be recorded as a tracked dependency, never implied.**
|
|
||||||
|
|
||||||
### First-class principle — no universally-quantified check may pass over an empty set
|
|
||||||
|
|
||||||
> **"All registered cases ran" is VACUOUSLY TRUE when there are no registered cases.**
|
|
||||||
> **Non-emptiness and anchoring are PRECONDITIONS asserted before the quantified check runs — not
|
|
||||||
> properties hoped for after it.**
|
|
||||||
>
|
|
||||||
> Promoted by Mos (2026-08-01) after the identical vacuity appeared **twice, at two different levels**:
|
|
||||||
> `activationCommit = HEAD` emptied the **commit range** (D-44), and an emptied manifest — `criteria`,
|
|
||||||
> `gates`, `proseClaims`, `compatibilityScenarios` all `[]` — emptied the **registry population**
|
|
||||||
> (D-46), each yielding **exit 0**. The first was fixed **as an instance**; the principle was never
|
|
||||||
> extracted, **so it returned one level up.**
|
|
||||||
>
|
|
||||||
> **Delete every gate and every criterion TOGETHER and no remaining reference complains — because every
|
|
||||||
> reference went with them.** A check that quantifies over a population must actually **range** over it,
|
|
||||||
> and that population must be **provably complete and non-empty**.
|
|
||||||
>
|
|
||||||
> **Corollary (same disease):** a clause written for the instance that produced it is not a clause.
|
|
||||||
> **Do not relabel the originating instances as the general clauses** — quantify over the population.
|
|
||||||
|
|
||||||
### 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)
|
## Decision record (authoritative, immutable)
|
||||||
|
|
||||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||||
@@ -202,31 +109,6 @@ 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. |
|
| **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. |
|
| **+ 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
|
## The finding that sets the cost
|
||||||
|
|
||||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||||
@@ -279,29 +161,6 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
|||||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||||
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
|
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||||
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
|
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||||
(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.
|
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||||
|
|||||||
+12
-1540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
# RM-61 — CI contract exemption for #1000 teardown artifact
|
||||||
|
|
||||||
|
**Tracking:** RM-61 / issue #1000
|
||||||
|
|
||||||
|
**Branch:** `fix/rm-61-ci-contract-exemption`
|
||||||
|
**Owner:** `coder-mos1`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
||||||
|
|
||||||
|
## Pre-registered kill criterion
|
||||||
|
|
||||||
|
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
||||||
|
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
||||||
|
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
||||||
|
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
||||||
|
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
||||||
|
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
||||||
|
|
||||||
|
## Initial evidence
|
||||||
|
|
||||||
|
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Requirements and kill criterion recorded before control implementation.
|
||||||
|
- [x] Historical full-JSON records captured.
|
||||||
|
- [x] Startup-failure control observed terminal.
|
||||||
|
- [x] Post-readiness crash control observed terminal.
|
||||||
|
- [x] Discrimination verdict recorded: Option B may proceed.
|
||||||
|
- [x] Conditional exemption implementation.
|
||||||
|
|
||||||
|
## Tests / evidence
|
||||||
|
|
||||||
|
### Control 1 — real startup failure
|
||||||
|
|
||||||
|
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
||||||
|
- Pipeline: #2189, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
||||||
|
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
||||||
|
- Pipeline/workflow: terminal `failure`.
|
||||||
|
|
||||||
|
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
||||||
|
|
||||||
|
### Control 2 — real post-readiness crash
|
||||||
|
|
||||||
|
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
||||||
|
- Pipeline: #2191, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
||||||
|
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
||||||
|
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
||||||
|
- `test`: `state=failure`, `exit_code=61`.
|
||||||
|
- Pipeline/workflow: terminal `failure`.
|
||||||
|
|
||||||
|
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
||||||
|
|
||||||
|
### Discrimination verdict
|
||||||
|
|
||||||
|
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
||||||
|
|
||||||
|
### Unit red-first checkpoint
|
||||||
|
|
||||||
|
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
||||||
|
|
||||||
|
### Control 2 setup attempt — invalid, excluded from evidence
|
||||||
|
|
||||||
|
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
||||||
|
- Pipeline: #2190, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- Service log: `/bin/sh: 0: -c requires an argument`.
|
||||||
|
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
||||||
|
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
||||||
|
|
||||||
|
## Implementation evidence
|
||||||
|
|
||||||
|
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
||||||
|
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
||||||
|
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
||||||
|
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
||||||
|
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
||||||
|
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
||||||
|
- Python compile: PASS.
|
||||||
|
- `pnpm typecheck`: PASS, 45/45 tasks.
|
||||||
|
- `pnpm lint`: PASS, 25/25 tasks.
|
||||||
|
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
||||||
|
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
||||||
|
|
||||||
|
## Independent review
|
||||||
|
|
||||||
|
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
||||||
|
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
||||||
|
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
||||||
|
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
||||||
|
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
||||||
|
|
||||||
|
## Documentation checklist
|
||||||
|
|
||||||
|
- [x] CI contract documented in the canonical framework CI/CD guide.
|
||||||
|
- [x] Operator command documented in the Woodpecker tool README.
|
||||||
|
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
||||||
|
- [x] Tracking and retirement cite issue #1000.
|
||||||
|
- [x] Both positive and negative guarantee boundaries are stated.
|
||||||
|
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 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.
|
||||||
+6
-3
@@ -6,11 +6,14 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"preflight": "node scripts/preflight.mjs",
|
||||||
"test": "turbo run test",
|
"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",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "husky"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ It is a **gate** role: the one and only merge path.
|
|||||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
||||||
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
||||||
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
|
||||||
|
record (including `clone`) with **`verify-terminal-green.py --expect-commit
|
||||||
|
<current-provider-PR-head>`** and record the equal expected/observed full-40
|
||||||
|
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
|
||||||
|
commit binding is a hard refusal. The verifier's sole interim
|
||||||
|
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
|
||||||
|
by #1000, and retires when #1000 is fixed. These scripts are the _only_
|
||||||
|
sanctioned merge path.
|
||||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
||||||
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||||
and `pr-ci-wait.sh`.
|
and `pr-ci-wait.sh`.
|
||||||
|
|||||||
@@ -868,6 +868,38 @@ steps:
|
|||||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||||
|
|
||||||
|
## Terminal-Green Full-Step Contract
|
||||||
|
|
||||||
|
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_HEAD=<full-40-hex-provider-head>
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
||||||
|
-r mosaicstack/stack -n <pipeline-number> -f json \
|
||||||
|
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
|
||||||
|
--expect-commit "$PR_HEAD" -
|
||||||
|
```
|
||||||
|
|
||||||
|
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
|
||||||
|
|
||||||
|
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
|
||||||
|
|
||||||
|
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
|
||||||
|
|
||||||
|
Only this exact conjunction is exempted:
|
||||||
|
|
||||||
|
- pipeline and workflow state are `success`;
|
||||||
|
- exactly one non-success child exists;
|
||||||
|
- its name is `ci-postgres` and type is `service`;
|
||||||
|
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
|
||||||
|
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
|
||||||
|
|
||||||
|
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
|
||||||
|
|
||||||
|
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
|
||||||
|
|
||||||
|
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
|
||||||
|
|
||||||
## Post-Merge CI Monitoring (Hard Rule)
|
## Post-Merge CI Monitoring (Hard Rule)
|
||||||
|
|
||||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||||
@@ -893,14 +925,16 @@ Woodpecker note:
|
|||||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
- 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.
|
||||||
|
|
||||||
## Gitea as Unified Platform
|
## Gitea as Unified Platform
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- PR target for delivery is `main`.
|
- PR target for delivery is `main`.
|
||||||
- Direct pushes to `main` are prohibited.
|
- Direct pushes to `main` are prohibited.
|
||||||
- Merge to `main` MUST be squash-only.
|
- Merge to `main` MUST be squash-only.
|
||||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||||
|
|
||||||
## Review Checklist
|
## 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`.
|
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.
|
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.
|
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 to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
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>`.
|
||||||
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).
|
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).
|
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).
|
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
|
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||||
> without asking.
|
> without asking.
|
||||||
|
|
||||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
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`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
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)
|
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.
|
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.
|
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||||
- Before merging, run queue guard:
|
- Before merging, run queue guard:
|
||||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||||
- Merge via wrapper:
|
- Merge via wrapper:
|
||||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- Wait for terminal CI status:
|
- Wait for terminal CI status:
|
||||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- Close linked issue after merge + green CI:
|
- 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
|
**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.
|
(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 main` 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 {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||||
|
|
||||||
## Git Scripts
|
## Git Scripts
|
||||||
|
|
||||||
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
|||||||
|
|
||||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
- 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/pr-ci-wait.sh -n {PR_NUMBER}`
|
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
|||||||
# Milestones
|
# Milestones
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
|
|
||||||
# CI queue guard (required before push/merge)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose 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)
|
### Code Review (Codex)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
BRANCH="main"
|
BRANCH=""
|
||||||
|
TARGET_REPO=""
|
||||||
|
HEAD_SHA=""
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
-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
|
||||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
--purpose VALUE Log context: push|merge (default: merge)
|
--purpose VALUE Log context: push|merge (default: merge)
|
||||||
@@ -27,63 +31,65 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
$(basename "$0") --purpose push -t 600 -i 10
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
get_state_from_status_json() {
|
get_state_from_status_json() {
|
||||||
python3 - <<'PY'
|
# 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 '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("status payload is not an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
print("unknown")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
statuses = payload.get("statuses") or []
|
raw_statuses = payload.get("statuses", [])
|
||||||
state = (payload.get("state") or "").lower()
|
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()
|
||||||
|
|
||||||
pending_values = {"pending", "queued", "running", "waiting"}
|
pending_values = {"pending", "queued", "running", "waiting"}
|
||||||
failure_values = {"failure", "error", "failed"}
|
failure_values = {"failure", "error", "failed"}
|
||||||
success_values = {"success"}
|
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 = []
|
values = []
|
||||||
for item in statuses:
|
for item in statuses:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
print("malformed")
|
||||||
value = (item.get("status") or item.get("state") or "").lower()
|
raise SystemExit(0)
|
||||||
if value:
|
raw_value = item.get("status") or item.get("state")
|
||||||
values.append(value)
|
if not isinstance(raw_value, str) or not raw_value:
|
||||||
|
print("malformed")
|
||||||
|
raise SystemExit(0)
|
||||||
|
values.append(raw_value.lower())
|
||||||
|
|
||||||
if not values and not state:
|
if any(value in pending_values for value in values) or state in pending_values:
|
||||||
print("no-status")
|
|
||||||
elif any(v in pending_values for v in values):
|
|
||||||
print("pending")
|
print("pending")
|
||||||
elif any(v in failure_values for v in values):
|
elif any(value in failure_values for value in values) or state in failure_values:
|
||||||
print("terminal-failure")
|
print("terminal-failure")
|
||||||
elif values and all(v in success_values for v in values):
|
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||||
print("terminal-success")
|
print("terminal-success")
|
||||||
|
elif not values:
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
PY
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 - <<'PY'
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -104,17 +110,61 @@ for item in statuses:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("context") or item.get("name") or "unknown-context"
|
name = item.get("context") or item.get("name") or "unknown-context"
|
||||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||||
target = item.get("target_url") or item.get("url") or ""
|
target = item.get("target_url") or item.get("url") or ""
|
||||||
if value in pending_values:
|
if value in pending_values:
|
||||||
found = True
|
found = True
|
||||||
if target:
|
suffix = f" ({target})" if target else ""
|
||||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||||
else:
|
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
|
||||||
if not found:
|
if not found:
|
||||||
print("[ci-queue-wait] no pending contexts")
|
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
|
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() {
|
github_get_branch_head_sha() {
|
||||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
local sha="$3"
|
||||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
gitea_get_branch_head_sha() {
|
gitea_get_branch_head_sha() {
|
||||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BRANCH="$2"
|
BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-R|--repo)
|
||||||
|
TARGET_REPO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sha)
|
||||||
|
HEAD_SHA="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
|||||||
echo "Error: timeout and interval must be integer seconds." >&2
|
echo "Error: timeout and interval must be integer seconds." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
||||||
|
|
||||||
OWNER=$(get_repo_owner)
|
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||||
REPO=$(get_repo_name)
|
echo "Error: --purpose must be push or merge." >&2
|
||||||
detect_platform > /dev/null
|
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
|
||||||
PLATFORM="${PLATFORM:-unknown}"
|
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 [[ "$PLATFORM" == "github" ]]; then
|
||||||
if ! command -v gh >/dev/null 2>&1; then
|
if ! command -v gh >/dev/null 2>&1; then
|
||||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
record_cannot_assert "github-cli-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||||
exit 1
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host) || {
|
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||||
echo "Error: Could not determine remote host." >&2
|
record_cannot_assert "remote-host-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
record_cannot_assert "credential-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
|
||||||
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
|
fi
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||||
exit 1
|
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
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
else
|
else
|
||||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
record_cannot_assert "unsupported-platform"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
|||||||
while true; do
|
while true; do
|
||||||
NOW_TS=$(date +%s)
|
NOW_TS=$(date +%s)
|
||||||
if (( NOW_TS > DEADLINE_TS )); then
|
if (( NOW_TS > DEADLINE_TS )); then
|
||||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||||
exit 124
|
exit 124
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||||
@@ -271,21 +459,24 @@ while true; do
|
|||||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
|
terminal-success)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 1
|
else
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
exit 3
|
||||||
exit 0
|
|
||||||
;;
|
;;
|
||||||
terminal-success|terminal-failure|unknown)
|
terminal-failure|malformed|unknown)
|
||||||
# Queue guard only blocks on pending/running/queued states.
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
EXPECT_HEAD=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -25,15 +25,14 @@ Options:
|
|||||||
-n, --number NUMBER PR number to merge (required)
|
-n, --number NUMBER PR number to merge (required)
|
||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-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
|
--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
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 42 # Merge PR #42
|
$(basename "$0") -n 42 # Merge PR #42
|
||||||
$(basename "$0") -n 42 -m squash # Squash merge
|
$(basename "$0") -n 42 -m squash # Squash merge
|
||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
DELETE_BRANCH=true
|
DELETE_BRANCH=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--skip-queue-guard)
|
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
--dry-run)
|
||||||
DRY_RUN=true
|
DRY_RUN=true
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--expect-head)
|
||||||
|
EXPECT_HEAD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|||||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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")"
|
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())')"
|
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
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
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
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$BASE_BRANCH" \
|
-B "$HEAD_BRANCH" \
|
||||||
|
-R "$HEAD_REPO" \
|
||||||
|
--sha "$HEAD_SHA" \
|
||||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||||
fi
|
fi
|
||||||
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
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() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
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"
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
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")
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
payload='{"Do":"squash"}'
|
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
|
||||||
|
)
|
||||||
|
|
||||||
token=$(get_gitea_token "$host" || true)
|
token=$(get_gitea_token "$host" || true)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
@@ -202,7 +209,7 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
"${cmd[@]}"
|
"${cmd[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -211,32 +218,9 @@ case "$PLATFORM" in
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
# 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.
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
merge_gitea_with_api "$HOST"
|
||||||
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
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ PY
|
|||||||
detect_platform > /dev/null
|
detect_platform > /dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||||
write_metadata "$METADATA"
|
write_metadata "$METADATA"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
|||||||
data.get('head_ref'),
|
data.get('head_ref'),
|
||||||
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(
|
base_ref = first_non_empty(
|
||||||
nested(data, 'base', 'ref'),
|
nested(data, 'base', 'ref'),
|
||||||
nested(data, 'base', 'name'),
|
nested(data, 'base', 'name'),
|
||||||
@@ -207,6 +226,8 @@ normalized = {
|
|||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': nested(data, 'user', 'login') or '',
|
'author': nested(data, 'user', 'login') or '',
|
||||||
'headRefName': head_ref,
|
'headRefName': head_ref,
|
||||||
|
'headRefOid': head_sha,
|
||||||
|
'headRepository': head_repo,
|
||||||
'baseRefName': base_ref,
|
'baseRefName': base_ref,
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
'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)],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# Covers:
|
# Covers:
|
||||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ case "$mode" in
|
|||||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||||
500) code=500; body='{"message":"internal server error"}' ;;
|
500) code=500; body='{"message":"internal server error"}' ;;
|
||||||
no-status) code=200; body='{}' ;;
|
no-status) code=200; body='{}' ;;
|
||||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
*)
|
*)
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
|||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
export GITEA_TOKEN="stub-token"
|
export GITEA_TOKEN="stub-token"
|
||||||
export GITEA_URL="https://git.example.test"
|
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
|
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
|||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||||
set +e
|
set +e
|
||||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||||
status_c=$?
|
status_c=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$status_c" -eq 0 ]]; then
|
if [[ "$status_c" -ne 0 ]]; then
|
||||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
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
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
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
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/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
|
#!/bin/bash
|
||||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -79,15 +79,24 @@ emit_response() {
|
|||||||
printf '200'
|
printf '200'
|
||||||
fi
|
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
|
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"},"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","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
POST_DATA="$post_data" python3 - <<'PY'
|
||||||
echo "unexpected merge payload: $post_data" >&2
|
import json
|
||||||
exit 96
|
import os
|
||||||
fi
|
payload = json.loads(os.environ["POST_DATA"])
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
|||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
OUTPUT="$SANDBOX/output.log"
|
OUTPUT="$SANDBOX/output.log"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||||
echo "--- output ---" >&2
|
echo "--- output ---" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
echo "--- mock log ---" >&2
|
echo "--- mock log ---" >&2
|
||||||
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -177,8 +154,8 @@ EOF
|
|||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
unset GITEA_LOGIN
|
unset GITEA_LOGIN
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
|
|||||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pr-merge.sh Gitea fallback regression passed"
|
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/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"
|
||||||
@@ -26,12 +26,13 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------- | -------------------------------------------- |
|
| -------------------------- | -------------------------------------------------------------- |
|
||||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||||
|
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
|
||||||
|
|
||||||
## Common Options
|
## Common Options
|
||||||
|
|
||||||
@@ -59,4 +60,9 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
# Block until one or more pipelines finish (event-driven CI wait)
|
# Block until one or more pipelines finish (event-driven CI wait)
|
||||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||||
|
|
||||||
|
# Verify the full JSON child-step record; do not use the text summary for this gate
|
||||||
|
PR_HEAD=<full-40-hex-provider-head>
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
|
||||||
|
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
|
||||||
```
|
```
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first contract harness for RM-61 / #1000.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
|
||||||
|
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
write_fixture() {
|
||||||
|
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
|
||||||
|
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
|
||||||
|
steps = [
|
||||||
|
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
|
||||||
|
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
|
||||||
|
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
|
||||||
|
]
|
||||||
|
json.dump({
|
||||||
|
"number": 9999,
|
||||||
|
"status": pipeline_status,
|
||||||
|
"commit": "a" * 40,
|
||||||
|
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
|
||||||
|
}, open(path, "w"))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_exit() {
|
||||||
|
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
|
||||||
|
set +e
|
||||||
|
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
|
||||||
|
actual=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$actual" -ne "$expected_exit" ]]; then
|
||||||
|
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'PASS %s\n' "$label"
|
||||||
|
printf '%s' "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ordinary terminal green.
|
||||||
|
write_fixture "$TMP/green.json" success success 0 '' success
|
||||||
|
out=$(expect_exit 0 green "$TMP/green.json")
|
||||||
|
grep -q '"total_steps": 3' <<<"$out"
|
||||||
|
grep -q '"exempted_steps": 0' <<<"$out"
|
||||||
|
|
||||||
|
# Exact, named #1000 teardown artifact: the only permitted non-success child.
|
||||||
|
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
|
||||||
|
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
|
||||||
|
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
|
||||||
|
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
|
||||||
|
grep -q '"exempted_steps": 1' <<<"$out"
|
||||||
|
|
||||||
|
# Negative controls: both real PostgreSQL failures must remain red.
|
||||||
|
write_fixture "$TMP/startup.json" failure failure 1 '' failure
|
||||||
|
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
|
||||||
|
write_fixture "$TMP/crash.json" failure failure 137 '' failure
|
||||||
|
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
|
||||||
|
|
||||||
|
# The exemption is signature-scoped, not step-scoped.
|
||||||
|
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
|
||||||
|
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
|
||||||
|
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
|
||||||
|
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
|
||||||
|
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
|
||||||
|
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
|
||||||
|
|
||||||
|
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
|
||||||
|
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
record = json.load(open(sys.argv[1]))
|
||||||
|
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
|
||||||
|
changed = json.loads(json.dumps(record))
|
||||||
|
changed["workflows"][0]["children"][1]["exit_code"] = value
|
||||||
|
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
|
||||||
|
PY
|
||||||
|
for label in false true float string null; do
|
||||||
|
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Exact artifact cannot mask any independent failure or non-success pipeline.
|
||||||
|
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
|
||||||
|
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
|
||||||
|
write_fixture "$TMP/skipped.json" success success 0 '' skipped
|
||||||
|
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
|
||||||
|
|
||||||
|
# The scanned pipeline must be bound to an explicit, full PR-head commit.
|
||||||
|
set +e
|
||||||
|
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
|
||||||
|
missing_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
|
||||||
|
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
|
||||||
|
|
||||||
|
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
record = json.load(open(sys.argv[1]))
|
||||||
|
record.pop("commit")
|
||||||
|
json.dump(record, open(sys.argv[2], "w"))
|
||||||
|
PY
|
||||||
|
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
|
||||||
|
|
||||||
|
printf 'terminal-green contract harness: PASS (17 cases)\n'
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify Mosaic's full-step Woodpecker terminal-green contract.
|
||||||
|
|
||||||
|
RM-61 permits one named, signature-scoped exception for issue #1000. The
|
||||||
|
exception retires when #1000 is fixed; all other non-success states block.
|
||||||
|
This program consumes the JSON/API record emitted by pipeline-status.sh -f json.
|
||||||
|
It does not fetch, retry, or re-trigger pipelines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EXEMPTION_ID = "WP-K8S-1000-CI-POSTGRES-TEARDOWN"
|
||||||
|
EXEMPTION_ISSUE = "https://git.mosaicstack.dev/mosaicstack/stack/issues/1000"
|
||||||
|
POD_NOT_FOUND = re.compile(
|
||||||
|
r'^pods "wp-svc-[0-9a-hjkmnp-tv-z]{26}-ci-postgres" not found$'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fail_usage(message: str) -> int:
|
||||||
|
print(f"terminal-green contract input error: {message}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def load_record(argument: str | None) -> dict[str, Any]:
|
||||||
|
if argument in (None, "-"):
|
||||||
|
value = json.load(sys.stdin)
|
||||||
|
else:
|
||||||
|
with Path(argument).open(encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("pipeline record must be a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def is_issue_1000_artifact(step: dict[str, Any]) -> bool:
|
||||||
|
error = step.get("error")
|
||||||
|
exit_code = step.get("exit_code")
|
||||||
|
return (
|
||||||
|
step.get("name") == "ci-postgres"
|
||||||
|
and step.get("type") == "service"
|
||||||
|
and step.get("state") == "failure"
|
||||||
|
and type(exit_code) is int
|
||||||
|
and not isinstance(exit_code, bool)
|
||||||
|
and exit_code == 0
|
||||||
|
and isinstance(error, str)
|
||||||
|
and POD_NOT_FOUND.fullmatch(error) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(record: dict[str, Any], expected_commit: str) -> tuple[int, dict[str, Any]]:
|
||||||
|
anomalies: list[dict[str, Any]] = []
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
pipeline_status = record.get("status")
|
||||||
|
actual_commit = record.get("commit")
|
||||||
|
if actual_commit != expected_commit:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "pipeline",
|
||||||
|
"name": str(record.get("number", "unknown")),
|
||||||
|
"state": pipeline_status,
|
||||||
|
"reason": "pipeline commit does not equal the expected PR head",
|
||||||
|
"expected_commit": expected_commit,
|
||||||
|
"actual_commit": actual_commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if pipeline_status != "success":
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "pipeline",
|
||||||
|
"name": str(record.get("number", "unknown")),
|
||||||
|
"state": pipeline_status,
|
||||||
|
"reason": "pipeline status is not success",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
workflows = record.get("workflows")
|
||||||
|
if not isinstance(workflows, list) or not workflows:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "pipeline",
|
||||||
|
"name": str(record.get("number", "unknown")),
|
||||||
|
"state": pipeline_status,
|
||||||
|
"reason": "workflows are missing or empty",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
workflows = []
|
||||||
|
|
||||||
|
for workflow_index, workflow in enumerate(workflows):
|
||||||
|
if not isinstance(workflow, dict):
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "workflow",
|
||||||
|
"name": str(workflow_index),
|
||||||
|
"state": None,
|
||||||
|
"reason": "workflow is not an object",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
workflow_name = str(workflow.get("name", workflow_index))
|
||||||
|
if workflow.get("state") != "success":
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "workflow",
|
||||||
|
"name": workflow_name,
|
||||||
|
"state": workflow.get("state"),
|
||||||
|
"reason": "workflow state is not success",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
children = workflow.get("children")
|
||||||
|
if not isinstance(children, list) or not children:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "workflow",
|
||||||
|
"name": workflow_name,
|
||||||
|
"state": workflow.get("state"),
|
||||||
|
"reason": "child-step list is missing or empty",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for child_index, child in enumerate(children):
|
||||||
|
if not isinstance(child, dict):
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "step",
|
||||||
|
"name": f"{workflow_name}[{child_index}]",
|
||||||
|
"state": None,
|
||||||
|
"reason": "step is not an object",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
steps.append(child)
|
||||||
|
if child.get("state") == "success":
|
||||||
|
continue
|
||||||
|
if is_issue_1000_artifact(child):
|
||||||
|
candidates.append(child)
|
||||||
|
continue
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "step",
|
||||||
|
"name": child.get("name"),
|
||||||
|
"type": child.get("type"),
|
||||||
|
"state": child.get("state"),
|
||||||
|
"exit_code": child.get("exit_code"),
|
||||||
|
"error": child.get("error"),
|
||||||
|
"reason": "non-success step does not match the #1000 teardown signature",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(candidates) > 1:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "exemption",
|
||||||
|
"name": EXEMPTION_ID,
|
||||||
|
"state": "invalid",
|
||||||
|
"reason": "the #1000 exemption may apply to exactly one step",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
exemption_applies = len(candidates) == 1 and not anomalies
|
||||||
|
state_counts = Counter(str(step.get("state", "missing")) for step in steps)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"schema_version": "mosaic-terminal-green/v1",
|
||||||
|
"verdict": "terminal-green" if not anomalies else "not-terminal-green",
|
||||||
|
"pipeline_number": record.get("number"),
|
||||||
|
"commit": actual_commit,
|
||||||
|
"expected_commit": expected_commit,
|
||||||
|
"pipeline_status": pipeline_status,
|
||||||
|
"total_steps": len(steps),
|
||||||
|
"state_counts": dict(sorted(state_counts.items())),
|
||||||
|
"exempted_steps": 1 if exemption_applies else 0,
|
||||||
|
"anomalies": anomalies,
|
||||||
|
}
|
||||||
|
if exemption_applies:
|
||||||
|
candidate = candidates[0]
|
||||||
|
result["exemptions"] = [
|
||||||
|
{
|
||||||
|
"exemption_id": EXEMPTION_ID,
|
||||||
|
"step": candidate.get("name"),
|
||||||
|
"signature": candidate.get("error"),
|
||||||
|
"tracking_issue": EXEMPTION_ISSUE,
|
||||||
|
"retires_when": "issue #1000 is fixed",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
result["exemptions"] = []
|
||||||
|
|
||||||
|
return (0 if not anomalies else 1), result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="verify the full Woodpecker terminal-green child-step contract"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--expect-commit",
|
||||||
|
required=True,
|
||||||
|
metavar="FULL_SHA",
|
||||||
|
help="full 40-hex PR-head commit that the pipeline record must match",
|
||||||
|
)
|
||||||
|
parser.add_argument("record", nargs="?", default="-", help="pipeline JSON file or -")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
if re.fullmatch(r"[0-9a-fA-F]{40}", arguments.expect_commit) is None:
|
||||||
|
parser.error("--expect-commit must be a full 40-hex commit")
|
||||||
|
arguments.expect_commit = arguments.expect_commit.lower()
|
||||||
|
return arguments
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = parse_arguments()
|
||||||
|
try:
|
||||||
|
record = load_record(arguments.record)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
return fail_usage(str(error))
|
||||||
|
code, result = verify(record, arguments.expect_commit)
|
||||||
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.48",
|
"version": "0.0.49",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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-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-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/woodpecker/test-terminal-green-contract.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": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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