Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3934e03fa6 | ||
|
|
fd26532757 | ||
|
|
c395ecae84 | ||
|
|
c5a5f9d362 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 |
+3
-13
@@ -68,25 +68,15 @@ steps:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
|
||||
# Anti-inert-gate registry. Deliberately unconditional: no path filter and no
|
||||
# step-level `when`, because a gate can be disabled by changes outside its own path.
|
||||
gate-verify:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm gate:verify
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- gate-verify
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# C1 detector gate. The fixture itself is intentionally RED; CI is green only
|
||||
# when its exact phase verdicts/reasons match the versioned expected-RED manifest.
|
||||
when:
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: [next, main]
|
||||
|
||||
steps:
|
||||
greenfield-git-present:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
||||
> /tmp/greenfield-git-present.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-git-present.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
next-git-present /tmp/greenfield-git-present.log "$fixture_status"
|
||||
|
||||
greenfield-main-git-present:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
||||
> /tmp/greenfield-main-git-present.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-main-git-present.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
main-git-present /tmp/greenfield-main-git-present.log "$fixture_status"
|
||||
|
||||
greenfield-git-absent:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
||||
> /tmp/greenfield-git-absent.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-git-absent.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
next-git-absent /tmp/greenfield-git-absent.log "$fixture_status"
|
||||
@@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
@@ -30,6 +30,16 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -38,10 +48,14 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
||||
- Node.js ≥ 20 and npm ≥ 9
|
||||
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
||||
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
|
||||
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
||||
|
||||
## Usage
|
||||
|
||||
### Launching Agent Sessions
|
||||
@@ -337,7 +351,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
@@ -358,15 +372,17 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
### Installer Flags
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Administrator Guide
|
||||
|
||||
- [Gate registry operations](quality-gate-registry.md)
|
||||
@@ -1,35 +0,0 @@
|
||||
# Gate Registry Operations
|
||||
|
||||
## Routine verification
|
||||
|
||||
Run `pnpm gate:verify` from a dependency-installed checkout. Exit zero means registry observations matched their declared `actual` values; it does **not** assert required-behavior conformance while deltas remain. Open `DEFECT` records are checked descriptions of current behavior with tracked owners, never successful gate outcomes.
|
||||
|
||||
Investigate any of these immediately:
|
||||
|
||||
- `GATE VERIFY FAILED` — registry structure, observed behavior, provenance, claim binding, source/deployment identity, or negative-control detection changed. The verifier aggregates independent phase failures, so repair the responsible stable-ID diagnostics as well as any accompanying stale-fixture error; do not treat the generic error as a substitute.
|
||||
- `unregistered gate` — an executable appeared under a declared gate root without a registry entry.
|
||||
- `no negative control` — a gate has no must-fail case.
|
||||
- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted.
|
||||
- `HISTORY_PROVENANCE_FORBIDDEN` — a history assertion path was reintroduced at the repository layer; remove it and keep RM-60 as the tracked external-boundary owner.
|
||||
|
||||
## Updating a gate
|
||||
|
||||
1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. Preserve recursive closed-schema validation for every nested object; new fields require explicit key and type handling.
|
||||
2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification.
|
||||
3. Declare an exact inerting mutation and observe the verifier detect it.
|
||||
4. If required and actual behavior differ, add a tracked remediation owner and justification.
|
||||
5. If meaning changed, append provenance; never replace the original silently.
|
||||
6. For an installed counterpart, verify live byte identity and update the observed digest only from measured evidence.
|
||||
7. Run focused verifier tests, `pnpm gate:verify`, and the repository baseline gates.
|
||||
|
||||
Do not add an ownerless exception or describe an open delta as pass/green/OK.
|
||||
|
||||
## CI behavior
|
||||
|
||||
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step needs no local history preparation because RM-02 asserts no history-provenance property.
|
||||
|
||||
**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. **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 registered shrink-both, overclaim, and per-gate evidence-subject controls must remain red for their stated reasons.
|
||||
|
||||
**DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor.
|
||||
|
||||
`scripts/gate-history-exclusion-control.mjs` enforces the output incapacity by testing alternate success wording and production renderer wiring; its registered fixture proves adding a prohibited success class goes red. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. If a bootstrap override is ever proposed before RM-60, it requires a separate explicit, loud, audited, retiring, negative-controlled design; RM-02 contains no override.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Developer Guide
|
||||
|
||||
- [Gate registry and negative controls](quality-gate-registry.md)
|
||||
@@ -1,43 +0,0 @@
|
||||
# Gate Registry and Negative Controls
|
||||
|
||||
`gates/gates.manifest.json` is the machine-readable registry for the initial RM-02 gate slice. Run:
|
||||
|
||||
```bash
|
||||
pnpm gate:verify
|
||||
```
|
||||
|
||||
## Registered slice
|
||||
|
||||
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
||||
|
||||
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every case result carries an evidence-side subject that is compared with the gate definition when the result is consumed; the population control mutates that evidence-side subject independently for every required gate. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
||||
|
||||
## Required versus actual
|
||||
|
||||
A case may record different `required` and `actual` outcomes only with a tracked owner. The verifier checks current reality against `actual`, prints each difference as `DEFECT (owner: ...)`, and fails when behavior changes without a matching registry update. A defect is never described as passing, green, or OK.
|
||||
|
||||
The queue guard currently has RM-03-owned deltas. In particular, its stdin/heredoc classifier does not consume piped status JSON, so terminal-success, no-status, and terminal-failure payloads become `unknown`; unknown and malformed states exit zero; push purpose defaults to `main`. RM-02 records these observations and does not edit the guard.
|
||||
|
||||
## Criteria, prose, and compatibility
|
||||
|
||||
Each criterion declares exact `caseRefs`; the verifier compares those semantic declarations bidirectionally with case-side `criterionIds` and requires at least one must-fail case. Moving a criterion ID to an unrelated case therefore fails as both a missing declared exercising case and an undeclared binding. Registered meta-negative controls misbind a criterion, remove meaning provenance, and misbind a prose claim, and each must make structure verification red for its stated reason.
|
||||
|
||||
Designated governing prose uses `GATE-CLAIM:<id>` markers. Each claim also names the exact must-fail `caseRef` that exercises its criterion; unknown, positive-only, unrelated, unbound, or registered-but-missing claims fail. Orchestrator-owned claims from `TASKS.md` are bound through `docs/remediation/GATE-CLAIMS.md`, which records source headings and anchored text without changing task tracking. Marker completeness still requires RM-54 review because arbitrary English claims cannot be inferred safely.
|
||||
|
||||
Compatibility checks detect direct contradictions in declared finite constructions. The verifier combines referenced case fixtures and environments in one isolated tree, rejects conflicting fixture/environment values, executes the construction's exact invocation, and checks its exact outcome. They do not prove semantic consistency of arbitrary natural language.
|
||||
|
||||
Restatements preserve original text, current text, reason, finding/task, and date.
|
||||
|
||||
## Source and deployed identity
|
||||
|
||||
A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality.
|
||||
|
||||
## Current-tree and history-provenance boundary
|
||||
|
||||
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population, verifier inventory, and `gates/required-gates.baseline.json` are compared inside the checkout. Evidence-side subjects are consumed and compared with gate definitions for every gate. **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.**
|
||||
|
||||
**DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed.
|
||||
|
||||
The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02.
|
||||
|
||||
Universal checks first prove populations non-empty. The production profile reads the same-checkout baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. A registered prose-claim control fails when the same-checkout mechanism is described as an adversarial protection.
|
||||
+33
-57
@@ -14,63 +14,6 @@
|
||||
|
||||
---
|
||||
|
||||
## RM-02 Gate Registry and Negative-Control Verifier (#1029)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Existing deterministic gates can return success without enforcing their stated property. RM-02 introduces a machine-readable registry and an unconditional CI verifier that distinguishes required behavior from observed behavior, proves every registered negative control can detect its own failure reason, and makes source/deployment drift visible.
|
||||
|
||||
### Scope
|
||||
|
||||
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; independent required-inventory comparison; evidence-side subject consumption; and enforced structural exclusion of history provenance until RM-60 provides protected external authority.
|
||||
|
||||
**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); history provenance before RM-60's provider-controlled/protected execution boundary; and a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `RM02-REQ-01`: The JSON registry SHALL give every gate and criterion a stable ID and SHALL declare exact invocation, input classes, cases, exact observed and required exit codes, reason diagnostics, and criterion bindings.
|
||||
2. `RM02-REQ-02`: Every gate SHALL have at least one observed-red must-fail case. The verifier SHALL reject missing, stale, ambiguous, or ineffective declared inert mutations and SHALL name an externally inerted gate.
|
||||
3. `RM02-REQ-03`: Every acceptance criterion SHALL declare exact criterion-side `caseRefs` that match case-side `criterionIds` bidirectionally and include a case that can fail for that criterion's stated reason. Moving a binding to an unrelated case SHALL fail verification. Security/integrity prose claims in the designated governing documents SHALL carry bound `GATE-CLAIM:<id>` markers and declare the exact must-fail case exercising the claim criterion.
|
||||
4. `RM02-REQ-04`: Declared finite compatibility scenarios SHALL execute together and direct modeled contradictions SHALL fail. This does not claim semantic consistency of arbitrary English.
|
||||
5. `RM02-REQ-05`: Restated criteria SHALL retain original text, current text, reason, finding/task, and dated meaning-change history.
|
||||
6. `RM02-REQ-06`: An observed behavior differing from required behavior SHALL be reported as `DEFECT` with a tracked owner; an ownerless delta SHALL fail verification. Such a gate SHALL never be described as passing, green, or OK.
|
||||
7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54.
|
||||
8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly.
|
||||
9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes.
|
||||
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance.
|
||||
11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from a same-checkout baseline and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. **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.**
|
||||
12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate.
|
||||
13. `RM02-REQ-13` (`D-40`): For every gate listed in the required inventory baseline, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion.
|
||||
14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and compared with the same-checkout baseline. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran.
|
||||
|
||||
#### RM02-REQ-10 meaning-change provenance
|
||||
|
||||
- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.”
|
||||
- **Restatement:** The repository verifier performs current-tree verification and is structurally incapable of asserting history provenance. RM-60 supplies the provider-controlled/protected pre-execution boundary before any repository-controlled lifecycle code executes.
|
||||
- **Reason:** `pnpm install` executes PR-controlled lifecycle code before `gate:verify`; therefore no local ref, git config, remote URL, constant, or author-positioned path in the checkout is a trustworthy history anchor. A local disclaimer would be a green wearing a note, not a property. The history-provenance claim is removed rather than weakened.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `RM02-AC-01`: A healthy current tree returns zero while prominently reporting the queue guard's required-versus-actual `DEFECT (owner: RM-03)` delta.
|
||||
2. `RM02-AC-02`: Externally mutate any registered gate at its declared inerting point so its failure path succeeds; verification returns nonzero and names that gate. The verifier's internal meta-control is observed red before its healthy result is trusted.
|
||||
3. `RM02-AC-03`: An executable added under a declared gate root without an entry returns nonzero and includes `unregistered gate`.
|
||||
4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`.
|
||||
5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason.
|
||||
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
||||
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, same-checkout inventory drift, and evidence-side subject consumption; it does not establish history provenance at all. **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.**
|
||||
8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; overstating the same-checkout inventory boundary; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern.
|
||||
9. `RM02-AC-09`: Population controls iterate every gate listed in the baseline and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls.
|
||||
|
||||
### Risks, dependencies, and verification boundary
|
||||
|
||||
- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, a same-checkout seven-gate baseline comparison, and evidence-side subject consumption.
|
||||
- **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state.
|
||||
- **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.** A registered claim control fails if the checked artifacts overstate this boundary.
|
||||
- The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently.
|
||||
- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Next.js frontend. It handles chat, projects, tasks, and LLM routing but lacks orchestration depth, agent coordination, shared memory, and remote access. The Mosaic framework (`~/.config/mosaic`) provides agent guides, shell-based orchestration tools, and quality rails — but these are loose scripts, not an integrated platform. The `@mosaicstack/*` packages in mosaic-mono-v0 began consolidating these into TypeScript packages (brain, queue, coord, cli, prdy, quality-rails) but have no UI, no auth, and no agent runtime integration.
|
||||
@@ -1425,3 +1368,36 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||
|
||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||
|
||||
---
|
||||
|
||||
## Greenfield install correctness — C1 (#1050)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
||||
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
||||
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
||||
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
||||
5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment.
|
||||
6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration.
|
||||
7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8.
|
||||
8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress.
|
||||
9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass.
|
||||
|
||||
### C1 acceptance criteria
|
||||
|
||||
1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent.
|
||||
2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail.
|
||||
3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation.
|
||||
4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869.
|
||||
|
||||
### Explicit exclusions and dependencies
|
||||
|
||||
- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills.
|
||||
- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric.
|
||||
- RM-02 and #869 activation are out of scope.
|
||||
|
||||
+5
-6
@@ -1,11 +1,5 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Gate verification
|
||||
|
||||
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, and the enforced RM-60 history-provenance exclusion. 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.
|
||||
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
||||
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
||||
|
||||
## Compaction refresh lease broker
|
||||
|
||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||
@@ -15,6 +9,11 @@
|
||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||
|
||||
## Installation and upgrades
|
||||
|
||||
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
||||
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
||||
|
||||
## CLI and skill management
|
||||
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Installer State Machine and Recovery
|
||||
|
||||
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
||||
|
||||
## Canonical phases
|
||||
|
||||
| Phase | Responsibility | Failure disposition |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
||||
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
||||
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
||||
| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot |
|
||||
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
||||
| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding |
|
||||
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
||||
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
||||
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
||||
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
||||
|
||||
The phase numbers are a cross-workstream contract and must not be renumbered.
|
||||
|
||||
## Side-effect-free check
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # stable/latest lane
|
||||
bash tools/install.sh --check --next # prerelease lane
|
||||
```
|
||||
|
||||
`--check`:
|
||||
|
||||
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
||||
- exits non-zero if any predicate fails;
|
||||
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
||||
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
||||
|
||||
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
||||
|
||||
## Durable journal
|
||||
|
||||
Each mutating run creates a private transaction directory:
|
||||
|
||||
```text
|
||||
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
||||
active.json
|
||||
<UTC-run-id>/
|
||||
journal.ndjson
|
||||
journal.ndjson.sha256 # committed runs only
|
||||
commands.log
|
||||
snapshot/
|
||||
```
|
||||
|
||||
Before each mutation scope is touched, `journal.ndjson` records:
|
||||
|
||||
- phase and path;
|
||||
- whether prior state existed and where its snapshot lives;
|
||||
- the reversal action;
|
||||
- the captured command-output location and command status.
|
||||
|
||||
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
||||
|
||||
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
||||
|
||||
`active.json` is the current projection:
|
||||
|
||||
- `in-progress`: incomplete/open transaction;
|
||||
- `rolled-back`: a fault restored the snapshot;
|
||||
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
||||
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
||||
- `committed`: P9 passed and the journal is sealed.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
1. Read the named phase and remediation line from installer stderr.
|
||||
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
||||
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
||||
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
||||
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
||||
|
||||
## Greenfield CI gate
|
||||
|
||||
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
||||
|
||||
The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest:
|
||||
|
||||
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
||||
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
||||
|
||||
The fixture is lane-parametric:
|
||||
|
||||
```bash
|
||||
bash tools/e2e-install-test.sh --lane next --git present
|
||||
bash tools/e2e-install-test.sh --lane main --git present
|
||||
```
|
||||
|
||||
CI exercises both lane parameters as expected-RED structural checks. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior.
|
||||
|
||||
## Source trust boundary
|
||||
|
||||
Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture.
|
||||
@@ -12,6 +12,20 @@ with no snapshot to fall back to.
|
||||
Protection is layered. Each layer is independent; a later layer catches what an
|
||||
earlier one misses.
|
||||
|
||||
## Layer 0 — Transaction journal (install-wide recovery)
|
||||
|
||||
The unified installer opens a private journal under
|
||||
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
||||
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
||||
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
||||
seals successful journals with a SHA-256 sidecar. See
|
||||
[Installer state machine and recovery](./installer-state-machine.md).
|
||||
|
||||
This transaction journal is distinct from the retained operator-only backup
|
||||
below. The transaction journal is required for correctness and rollback;
|
||||
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
||||
for a manifest bug that the normal transaction did not detect.
|
||||
|
||||
## Layer 1 — Manifest-owned sync (prevention)
|
||||
|
||||
The single source of truth for ownership is
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# RM-02 Gate Registry Implementation Plan
|
||||
|
||||
> **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor.
|
||||
|
||||
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed.
|
||||
|
||||
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the same-checkout `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. 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. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary.
|
||||
|
||||
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Meta-negative-control kernel
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/gate-verify.test.mjs`
|
||||
- Create: `scripts/gate-verify.mjs`
|
||||
|
||||
1. Write a black-box fixture whose gate failure branch has already been changed to success.
|
||||
2. Run `node --test scripts/gate-verify.test.mjs`; require failure because the absent verifier does not name the inert gate.
|
||||
3. Implement manifest loading, exact process execution, and named mismatch reporting only.
|
||||
4. Re-run and require the external inert mutation to produce verifier nonzero while the test passes.
|
||||
5. Add an internally applied declared mutation and require a healthy fixture to report its negative control armed.
|
||||
|
||||
### Task 2: Registry structural clauses
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `scripts/gate-verify.test.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
|
||||
Add failing tests, one behavior at a time, for: `unregistered gate`; `no negative control`; unbound criterion; unbound `GATE-CLAIM`; ownerless required/actual delta; stale/ambiguous/ineffective mutation; direct modeled conflict; missing meaning-change provenance. Implement the minimum validator after each observed RED.
|
||||
|
||||
### Task 3: Gate fixtures and seven-gate manifest
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `gates/gates.manifest.json`
|
||||
- Create: `gates/fixtures/quality-case.mjs`
|
||||
- Create: `gates/fixtures/preflight-case.mjs`
|
||||
- Create: `gates/fixtures/queue-case.sh`
|
||||
- Create: `gates/fixtures/hook-case.sh`
|
||||
- Modify: `scripts/gate-verify.test.mjs`
|
||||
|
||||
Register typecheck, lint, format, RM-01 preflight, queue guard, pre-commit, and pre-push. For each, first run its known-bad case against an intentionally inert fixture and observe verifier RED; then restore the real gate behavior and require its exact observed exit/reason. Record queue defects as required/actual deltas owned by RM-03, never as pass/green/OK.
|
||||
|
||||
### Task 4: Source-versus-deployed identity
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
- Modify: `scripts/gate-verify.test.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
|
||||
Write and observe a failing test with a byte-mutated deployed counterpart. Implement byte equality and internal drift mutation controls. Declare `none` explicitly for gates without deployed counterparts.
|
||||
|
||||
### Task 5: Prose claims and compatibility constructions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/remediation/MISSION.md`
|
||||
- Modify: `docs/remediation/TASKS.md` only if coordinator authorization overrides the worker prohibition; otherwise place markers in an RM-02 claim index that references immutable source anchors.
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
- Modify: verifier tests/implementation.
|
||||
|
||||
Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model.
|
||||
|
||||
### Task 6: Current-tree boundary and enforced history-provenance exclusion
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `gates/required-gates.baseline.json`
|
||||
- Create: `scripts/gate-history-exclusion-control.mjs`
|
||||
- Create: `scripts/gate-inventory-shrink-control.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
|
||||
Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the same-checkout baseline, register must-fail attacks that shrink either paired representation, and register an overclaim control. 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. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary.
|
||||
|
||||
### Task 7: CI and documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `.woodpecker/ci.yml`
|
||||
- Create/update: `docs/DEVELOPER-GUIDE/quality-gate-registry.md`
|
||||
- Create/update: `docs/ADMIN-GUIDE/quality-gate-registry.md`
|
||||
- Modify: `docs/SITEMAP.md`
|
||||
|
||||
Add `gate:verify`; run it in an unconditional PR/main CI step. Document invocation, defect semantics, coverage/exclusions, marker syntax, replay/provider boundaries, and source/deployed identity.
|
||||
|
||||
### Task 8: Verification and delivery
|
||||
|
||||
Run focused tests, `pnpm gate:verify`, checkout tests, typecheck, lint, format, and applicable integration tests. Run Codex code and security review; remediate and re-review. Verify authorship, commit, execute queue guard before push, push, create PR via Mosaic wrapper, and send exact-head review request to rev-974 through the coordinator. Do not merge without coordinator authorization.
|
||||
@@ -1,40 +0,0 @@
|
||||
# RM-02 Governing Claim Index
|
||||
|
||||
This index binds remediation claims that live in orchestrator-owned `TASKS.md` without modifying that file. The source heading and quoted text are the reviewable anchor; `gate:verify` enforces each marker-to-criterion binding. Marker completeness beyond this approved slice remains RM-54.
|
||||
|
||||
## Prose is an enforceable claim
|
||||
|
||||
<!-- GATE-CLAIM:PROSE-IS-A-CLAIM -->
|
||||
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically`.
|
||||
- Anchored text: “The defect was not in the code — it was in this file.”
|
||||
|
||||
## Generated-state verification scope
|
||||
|
||||
<!-- GATE-CLAIM:GENERATED-STATE-SCOPE -->
|
||||
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-19 — an integrity property that cannot exist at the layer it was specified`.
|
||||
- Anchored text: “a verifier that cannot detect the attack is not a verifier.”
|
||||
|
||||
## Execution trust boundary
|
||||
|
||||
<!-- GATE-CLAIM:EXECUTION-TRUST-BOUNDARY -->
|
||||
|
||||
- Source: RM-02 ruling recorded under `docs/remediation/TASKS.md`, RM-02 clause 4, D-25.
|
||||
- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.”
|
||||
- Dependency: RM-60/#1031, cross-referenced with RM-59.
|
||||
|
||||
## Same-checkout inventory drift boundary
|
||||
|
||||
<!-- GATE-CLAIM:INVENTORY-DRIFT-BOUNDARY -->
|
||||
|
||||
- Source: RM-02 ruling after D-48/CWE-353 reproduced against the round-4 baseline.
|
||||
- Boundary: 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.
|
||||
- Negative control: `checkout-preflight/inventory-claim-overstatement` rewrites the boundary as protection and must go red.
|
||||
|
||||
## Criterion restatement provenance
|
||||
|
||||
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
|
||||
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation`.
|
||||
- Anchored text: “Both criteria were pre-registered. They cannot both be satisfied.”
|
||||
@@ -12,8 +12,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — observe the property, not the exit code
|
||||
|
||||
<!-- GATE-CLAIM:OBSERVE-PROPERTY -->
|
||||
|
||||
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||
>
|
||||
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||
@@ -32,8 +30,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||
|
||||
<!-- GATE-CLAIM:PREREGISTRATION-BOUNDARY -->
|
||||
|
||||
> **A pre-registered check set can fail in three distinct ways:**
|
||||
>
|
||||
> | mode | the set is… | found as |
|
||||
@@ -60,8 +56,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### Corollary — never ship an integrity claim dressed as a property
|
||||
|
||||
<!-- GATE-CLAIM:ARTIFACT-INTEGRITY-BOUNDARY -->
|
||||
|
||||
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||
@@ -74,8 +68,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — when a property cannot exist at the layer it was specified
|
||||
|
||||
<!-- GATE-CLAIM:IMPOSSIBLE-LAYER-BOUNDARY -->
|
||||
|
||||
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||
> there are exactly three honest moves, and all three are mandatory:
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# RM-02 Gate Registry Scratchpad (#1029)
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02-gate-registry`, preserving required-versus-actual defects without laundering them as success.
|
||||
|
||||
## Constraints and boundaries
|
||||
|
||||
- Do not modify `ci-queue-wait.sh`; RM-03 owns that fix.
|
||||
- Do not modify `docs/remediation/TASKS.md`; workers cannot edit orchestrator tracking.
|
||||
- Every declared behavior must be observed, not inferred from an exit code.
|
||||
- Repository-local evidence does not establish same-authority tamper resistance; RM-25/RM-59 own the external trust anchor.
|
||||
- Coverage is seven logical gates; broader inventory is RM-54.
|
||||
- Budget assumption: no explicit token ceiling was supplied. Keep implementation dependency-free (stock Node), avoid repeated full monorepo installs, and keep generated test artifacts under the worktree/main disk.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Update PRD and tracking references.
|
||||
2. Write black-box meta-negative-control first and observe it fail for the missing verifier behavior.
|
||||
3. Implement minimal manifest parser/case runner/mutation detector; observe meta-control succeed.
|
||||
4. Add schema, coverage, provenance, prose marker, compatibility, discovery, deployment identity, and defect-delta tests RED-first.
|
||||
5. Register seven gates with exact cases and run each case.
|
||||
6. Add prospective per-commit replay and bounded provider-evidence reporting.
|
||||
7. Wire unconditional Woodpecker CI and update developer/admin documentation.
|
||||
8. Run situational and baseline verification, independent Codex review, remediate, commit, queue guard, push, PR, coordinator/reviewer handoff.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-08-01: Design approved by `mos-remediation`; rulings A-E and source/deployed identity addition incorporated.
|
||||
- 2026-08-01: Isolated worktree created from `origin/main` f65e9ea6; RM-01 f58b3699 verified as ancestor.
|
||||
- 2026-08-01: Git author set to `f10-coder <[email protected]>`; provider issue #1029 created with `MOSAIC_GIT_IDENTITY=f10-coder`.
|
||||
- 2026-08-01: Source/deployed queue-guard SHA-256 observed equal (`19cda2f...`); this observation is not yet an enforced property.
|
||||
|
||||
## Tests and RED-first evidence
|
||||
|
||||
- Meta-negative RED first: `node --test scripts/gate-verify.test.mjs` failed because the absent verifier did not name externally inerted `meta-fixture`.
|
||||
- Structural RED: nine tests failed before implementation for internal mutation, unregistered executable, missing negative control, ownerless delta, unbound criterion/claim, modeled conflict, missing provenance, and deployment drift.
|
||||
- Clause hardening RED: positive-only security criterion and missing registered claim marker both passed incorrectly before validation was added.
|
||||
- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring.
|
||||
- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented.
|
||||
- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`.
|
||||
- Focused Node tests: 54/54 pass after third-round hardening (36 verifier/wiring plus 18 history/provider tests).
|
||||
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
||||
- `pnpm lint`: pass (25/25 Turbo tasks).
|
||||
- `pnpm format:check`: pass.
|
||||
- `pnpm test`: repository suites reached 45/46 Turbo tasks; all application/package tests shown passed, then the known host-specific wake assertion aborted exit 97 (`BASH_LINENO convention violated`, #973/D-16). No test was edited or bypassed. CI remains the authoritative full-suite environment.
|
||||
|
||||
## Self-surfaced defect
|
||||
|
||||
The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while provider JSON is piped to the shell function. The heredoc owns stdin, so Python never reads provider JSON. Terminal success, no-status, terminal failure, and malformed payloads all classify as `unknown`; the associated fail-open outcomes are recorded under RM-03. No queue-guard source was modified.
|
||||
|
||||
## Independent review remediation
|
||||
|
||||
- Final pre-commit Codex code review found three blockers: a tautological deployment drift control, independently observed rather than combined compatibility cases, and unauthorized edits to orchestrator-owned `TASKS.md`.
|
||||
- Deployment identity now uses one shared file comparator for both live equality and a temporary drifted deployed copy; a test makes that comparator inert and proves the meta-control fails.
|
||||
- Compatibility scenarios now merge referenced fixtures/environments into one isolated construction and execute an exact scenario invocation; a test proves two independently valid fixtures coexist in the combined run.
|
||||
- `TASKS.md` changes were reverted. `docs/remediation/GATE-CLAIMS.md` binds source headings and anchored text without editing orchestrator tracking.
|
||||
- Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible.
|
||||
- Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three.
|
||||
- Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings.
|
||||
- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix.
|
||||
- Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability.
|
||||
- `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention.
|
||||
- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors. Parent-generated launcher/entry metadata distinguishes refusal before sandbox entry from child-controlled output; the detector recognizes exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT` and known namespace-refusal text only when the entry command provably did not run. Focused negative assertions reject unrelated `spawnSync git EPERM`, verifier output that merely says `bwrap ENOENT`, and exact namespace-denial impersonation without provenance or after sandbox entry.
|
||||
- Exact-head independent review at `38f1b249` found one valid diagnostic-masking blocker: canonical verification knew four stable-ID rebinding failures but a thrown stale fixture replacement reached the outer catch first and emitted only the generic error. RED-first reproduction confirmed the canonical path omitted both responsible criterion IDs. Verification now collects labeled failures independently across claims, discovery, deployment, case execution/outcome checks, mutation, and compatibility, preserving structural stable-ID failures alongside the stale-fixture signal. The canonical regression test requires both missing-binding IDs and the generic fixture error.
|
||||
- Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation.
|
||||
- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings.
|
||||
|
||||
- Exact-head review at `83d2ecb2` found four silent-defeat paths. Genuine RED-first tests on the pre-fix code proved: author-controlled HEAD/parent/introduction seams produced no boundary failure; cross-commit duplicate pipeline number 7 returned terminal-success; and misspelling `outputPattern` as `outputPatern` in required/actual left canonical verification green. Fixes derive the seam from Git, validate provider identity globally before subject filtering, and recursively close/type-check nested schemas. New registry criteria `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, and `RM02-HISTORY-BOUNDARY` are bidirectionally bound to eight registered must-fail controls. The broad nested-object typo table and exact derived-boundary test are regression guards added after implementation, not claimed as RED-first. Pre-commit Codex review then found that global evidence failure was only observed—not failed—when HEAD was the sole prospective commit, and that non-object collection entries threw before normalization. Both were reproduced RED-first, then fixed by one collection validator used before iteration and by per-commit assessment. Follow-up review found collection validation still omitted exactly-one-gate-step cardinality for unrelated subjects; a focused test reproduced terminal-success RED-first, and collection validation now rejects that ambiguity globally. Security review then found present-but-empty outcome patterns were truthy-optional assertion bypasses; a reason-specific test reproduced that they lacked the required schema diagnostic, and present pattern fields now require non-whitespace content.
|
||||
|
||||
- Exact-head review at `32b490a7` found three population-level blockers. Genuine RED-first tests proved a gate change before author-delayed registry introduction fell outside the range, and empty criteria/gates/prose/scenario populations returned zero. History now anchors at the provider target merge-base; delayed introduction is a registered must-fail control. Production verification requires non-empty populations and a hardcoded seven-gate ID/source inventory before quantified checks. D-38/D-40 criteria now quantify over `gateRefs` exactly spanning every registered gate; each gate declares its evidence subject, and population controls mutate evidence subject and comparison type for every gate. The history record states both bootstrap directions: sound against a branch author unable to rewrite main, not sound against compromised/rewritten main, with residual owned by Builds 1-2. Pre-commit review rejected an initial production CLI `--fixture-profile` test relaxation as a vacuity bypass. That flag was removed; synthetic fixtures now use a non-executable test-support runner, while regression tests prove the shipped CLI rejects the flag and production population checks remain mandatory. Follow-up review then proved deleting `gateRefs` skipped population validation; a RED-first loop reproduced all three deletions, and the three general criterion IDs now require the field before exact-span validation.
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated.
|
||||
- User/API documentation not applicable: no user workflow or API changed.
|
||||
- Independent review documentation check pending rev-974 at the revised exact head.
|
||||
- Canonical documentation remains in-repository; no external publication requested.
|
||||
|
||||
## Rebase onto RM-61
|
||||
|
||||
- Rebasing `f9746b23` onto main `f4fd5967` completed mechanically with no conflicts.
|
||||
- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. An initial manifest update to `f4fd5967` had the right value but retained an author-controlled mechanism. Independent mutation proved HEAD, HEAD's parent, and the introduction commit could each make history coverage vacuous or partial. The manifest field is now forbidden; the verifier derives the boundary as the parent of the first first-parent registry-introduction commit, and registered must-fail controls reject all three unsafe candidates.
|
||||
|
||||
## Risks/blockers
|
||||
|
||||
- Current queue guard intentionally has required-versus-actual deltas owned by RM-03.
|
||||
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion.
|
||||
- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert.
|
||||
- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality.
|
||||
|
||||
## coder-mos2 remediation — fourth round
|
||||
|
||||
- Coordinator correction loaded: Blocker 2 requires a genuine shrink-both control against an independent inventory baseline; Blocker 3 requires evidence-side subjects consumed against gate definitions for every gate; anchor-dependent history provenance must be removed rather than relabelled because PR lifecycle code makes all local git state untrustworthy before verification.
|
||||
- Boundary to preserve in both directions: this repository layer DOES verify current-tree registered cases, independently anchored inventory shape, evidence subject consumption, and an enforced absence of history-provenance claims. It DOES NOT establish history provenance at all. RM-60 owns the provider-controlled/protected pre-execution boundary required for that property; no local ref, config, URL, constant, or author-positioned path is treated as an anchor.
|
||||
- TDD plan: add three genuine red-first controls before implementation: shrink verifier inventory plus manifest together; mutate evidence-side subject for every gate; and reject the currently enabled history verifier/import/report path. Existing controls retained as regression guards and labelled honestly.
|
||||
- Identity observation before first commit: `git var GIT_AUTHOR_IDENT` returned `coder-mos2 <[email protected]>` using process-scoped author/committer variables; shared repository config was not modified.
|
||||
- Genuine RED-first evidence: `node --test scripts/gate-remediation.test.mjs` produced 0/3 passing on frozen head `fbb61912`: source+manifest shrink exited zero, no evidence-side case subjects existed, and the production verifier still imported/invoked history verification. These were failures for the three stated blocker reasons, not regression guards.
|
||||
- Sixth mutation found before review: after the first baseline implementation, shrinking the new baseline and manifest together while leaving the verifier inventory unchanged still exited zero. A new test observed that attack RED first. Inventory equality diagnostics are now bidirectional, and the registered shrink control attacks both source+manifest and baseline+manifest pairs.
|
||||
- Regression guards retained and honestly labelled: manifest-only shrink, exact `gateRefs` span, production fixture-profile rejection, and broad nested schema checks already blocked before this round.
|
||||
- Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red.
|
||||
- Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class.
|
||||
- Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence.
|
||||
- Renderer remediation: every non-error production observation and final summary now routes through the closed current-tree output renderer. The exclusion control additionally requires one stdout sink, exactly two `GATE VERIFY FAILED` stderr sinks, no console sinks, and renderer use for both per-observation and final-summary paths. Regression attacks cover allowlisted history/ancestry/lineage wording, writer assertion removal, direct final-success stdout, and direct success stderr; focused suite 41/41, `pnpm gate:verify` green with six declared RM-03 deltas, and format check green.
|
||||
- Coordinator ruling after CWE-353: retain the same-checkout inventory comparison but narrow its claim. Canonical current boundary: “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.” This supersedes the earlier “independent/anchored” wording in the historical entries above; those entries remain as append-only evidence, not current claims.
|
||||
- Genuine RED-first overclaim control: before claim remediation, `node scripts/gate-inventory-claim-control.mjs` exited 84 and named every artifact missing the narrowed boundary plus each current overclaim. The registered `inventory-claim-overstatement` case now rewrites the baseline purpose to a protection claim and must exit 84 with `INVENTORY_CLAIM_OVERSTATED`. Existing shrink-pair controls remain regression guards for the same-checkout drift property, not adversarial-integrity claims.
|
||||
- Follow-up code review found a genuine propagation/control gap: `docs/PRD.md` still said “independent seven-gate baseline,” and the first forbidden regex did not match the intervening qualifier. The reviewer observed the control green against that overclaim. The PRD now says same-checkout, the detector rejects `independent … baseline` across bounded same-line qualifiers, the registered mutation uses the exact qualified wording, and a focused behavioral test requires exit 84 for it. Follow-up security review passed with risk `none` and explicitly accepted the narrowed RM-60 boundary.
|
||||
@@ -0,0 +1,68 @@
|
||||
# #1050 — Installer P0–P9 state machine and red-first fixture
|
||||
|
||||
## Objective
|
||||
|
||||
Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS.
|
||||
|
||||
## Authority and scope
|
||||
|
||||
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
||||
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
||||
- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane.
|
||||
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
||||
- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer.
|
||||
2. Commit the immutable red-first acceptance fixture before implementation.
|
||||
3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms.
|
||||
4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user.
|
||||
5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path.
|
||||
|
||||
## Budget
|
||||
|
||||
- Working estimate: 32K reasoning/output tokens.
|
||||
- Hard external cap: none stated.
|
||||
- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it.
|
||||
|
||||
## Pre-registered acceptance checks
|
||||
|
||||
| ID | Exact case | Expected pre-fix result |
|
||||
|---|---|---|
|
||||
| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons |
|
||||
| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts |
|
||||
| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates |
|
||||
| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state |
|
||||
| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero |
|
||||
| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version |
|
||||
| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 |
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
||||
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
||||
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
||||
- [x] Issue #1050 created and provider author read back.
|
||||
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
||||
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
||||
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
||||
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
||||
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
||||
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match.
|
||||
- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
||||
- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior.
|
||||
- #869 must remain staged and inactive.
|
||||
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
||||
|
||||
## Verification log
|
||||
|
||||
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
||||
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization.
|
||||
- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch.
|
||||
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift.
|
||||
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits.
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"purpose": "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.",
|
||||
"gates": [
|
||||
{
|
||||
"id": "quality-typecheck",
|
||||
"source": "package.json"
|
||||
},
|
||||
{
|
||||
"id": "quality-lint",
|
||||
"source": "package.json"
|
||||
},
|
||||
{
|
||||
"id": "quality-format",
|
||||
"source": "package.json"
|
||||
},
|
||||
{
|
||||
"id": "checkout-preflight",
|
||||
"source": "scripts/preflight.mjs"
|
||||
},
|
||||
{
|
||||
"id": "ci-queue-wait",
|
||||
"source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh"
|
||||
},
|
||||
{
|
||||
"id": "hook-pre-commit",
|
||||
"source": ".husky/pre-commit"
|
||||
},
|
||||
{
|
||||
"id": "hook-pre-push",
|
||||
"source": ".husky/pre-push"
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"gate:verify": "node scripts/gate-verify.mjs",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||
"test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
|
||||
@@ -925,14 +925,16 @@ Woodpecker note:
|
||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||
|
||||
## Gitea as Unified Platform
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (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
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines 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).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||
> without asking.
|
||||
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||
|
||||
@@ -425,11 +425,11 @@ git push
|
||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B 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):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- 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:
|
||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- Close linked issue after merge + green CI:
|
||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B 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
|
||||
|
||||
@@ -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/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.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
|
||||
~/.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
|
||||
```
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -58,6 +58,7 @@ done
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
@@ -222,12 +223,14 @@ prune_durable_snapshots() {
|
||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||
list="$(mktemp)"
|
||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||
# than risk pruning in an undefined order.
|
||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
while IFS= read -r d; do
|
||||
@@ -266,7 +269,11 @@ make_durable_snapshot() {
|
||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$root" 2>/dev/null || true
|
||||
if ! chmod 700 "$root"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
|
||||
return 0
|
||||
fi
|
||||
dir="$root/pre-update-$ts"
|
||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||
@@ -281,7 +288,10 @@ make_durable_snapshot() {
|
||||
if ! enumerate_operator_files "$list"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
||||
rm -f "$list"
|
||||
if ! rmdir "$dir"; then
|
||||
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' rel; do
|
||||
@@ -292,12 +302,18 @@ make_durable_snapshot() {
|
||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||
continue
|
||||
fi
|
||||
chmod 600 "$dst" 2>/dev/null || true
|
||||
if ! chmod 600 "$dst"; then
|
||||
rm -f "$dst"
|
||||
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
|
||||
continue
|
||||
fi
|
||||
count=$((count + 1))
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
||||
# Tighten every dir the copy created (mkdir -p already honored umask 077).
|
||||
if ! find "$dir" -type d -exec chmod 700 {} +; then
|
||||
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
|
||||
fi
|
||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||
DURABLE_SNAPSHOT_DIR="$dir"
|
||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||
@@ -344,7 +360,9 @@ verify_operator_surface() {
|
||||
continue
|
||||
fi
|
||||
if cp "$snap" "$cur"; then
|
||||
chmod 600 "$cur" 2>/dev/null || true
|
||||
if ! chmod 600 "$cur"; then
|
||||
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
|
||||
fi
|
||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||
healed=$((healed + 1))
|
||||
else
|
||||
@@ -535,7 +553,7 @@ sync_framework_keep() {
|
||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||
if ! find "$dst/$root" -type d -empty -delete; then
|
||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||
fi
|
||||
done < <(manifest_subtree_roots)
|
||||
@@ -581,7 +599,7 @@ run_migrations() {
|
||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||
rm -rf "$TARGET_DIR/bin"
|
||||
rm -rf "${TARGET_DIR:?}/bin"
|
||||
fi
|
||||
|
||||
# Remove old mosaic PATH entry from shell profiles
|
||||
@@ -706,13 +724,23 @@ mkdir -p "$TARGET_DIR/credentials"
|
||||
# by `mosaic init` from templates with user-supplied values.
|
||||
reconcile_framework_files
|
||||
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||
# Ensure tool scripts are executable. These are P4 postconditions, not
|
||||
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
|
||||
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
|
||||
fail "Could not mark shipped shell tools executable."
|
||||
exit 1
|
||||
fi
|
||||
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
|
||||
fail "Could not mark shipped runtime scripts executable."
|
||||
exit 1
|
||||
fi
|
||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension.
|
||||
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
|
||||
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
|
||||
fail "Could not mark git-credential-mosaic executable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok "Framework synced to $TARGET_DIR"
|
||||
|
||||
@@ -739,49 +767,110 @@ step "Post-install tasks"
|
||||
|
||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||
|
||||
# Capture every fallible post-install command. A failure's text is surfaced and
|
||||
# also appended to the parent transaction's private command log. Failure to
|
||||
# write that log is fatal: continuing would recreate the false-clean diagnosis
|
||||
# INV-C forbids.
|
||||
record_phase_outcome() {
|
||||
local phase="$1" status="$2" reason="$3"
|
||||
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
|
||||
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|
||||
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
|
||||
fail "Could not durably record $phase action outcome for the parent transaction."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_captured() {
|
||||
local label="$1" output status=0
|
||||
shift
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")"
|
||||
if "$@" >"$output" 2>&1; then status=0; else status=$?; fi
|
||||
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
|
||||
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|
||||
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
|
||||
cat "$output" >&2
|
||||
rm -f "$output"
|
||||
fail "Could not durably append '$label' diagnostics to the install command log."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi
|
||||
rm -f "$output"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||
link_args=()
|
||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||
# stdout is suppressed as before, but stderr is left connected: the
|
||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
||||
# the operator, not be swallowed silently.
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
||||
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
|
||||
record_phase_outcome P6 committed "runtime asset linker exited zero"
|
||||
ok "Runtime assets linked"
|
||||
else
|
||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
||||
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
|
||||
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
|
||||
fi
|
||||
else
|
||||
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
|
||||
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
||||
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
|
||||
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
|
||||
ok "sequential-thinking MCP configured"
|
||||
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
|
||||
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
|
||||
else
|
||||
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
|
||||
else
|
||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||
exit 1
|
||||
fi
|
||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
||||
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
|
||||
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
|
||||
ok "excalidraw MCP configured"
|
||||
else
|
||||
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||
record_phase_outcome P4 failed "required skills sync explicitly skipped"
|
||||
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
|
||||
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
|
||||
record_phase_outcome P4 committed "skills sync exited zero"
|
||||
ok "Skills synced"
|
||||
else
|
||||
record_phase_outcome P4 failed "skills sync exited non-zero"
|
||||
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
|
||||
fi
|
||||
else
|
||||
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
|
||||
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
||||
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
|
||||
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
|
||||
ok "Local skills migrated"
|
||||
else
|
||||
record_phase_outcome P4 failed "local skills migration exited non-zero"
|
||||
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
||||
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
|
||||
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
|
||||
ok "Health audit passed"
|
||||
else
|
||||
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write version stamp AFTER everything succeeds
|
||||
# The version stamp records the successfully committed framework file sync.
|
||||
# Post-install failures are carried separately into P4/P6 and cannot be erased
|
||||
# by this stamp.
|
||||
write_framework_version
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run 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`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run 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)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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`).
|
||||
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.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run 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`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run 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)
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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`).
|
||||
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.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run 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`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run 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)
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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`).
|
||||
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.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run 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`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run 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)
|
||||
|
||||
|
||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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`).
|
||||
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.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run 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`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run 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)
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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`).
|
||||
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.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run 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`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run 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)
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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`).
|
||||
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.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH="main"
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
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:
|
||||
-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)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
@@ -27,63 +31,65 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
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 sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
statuses = raw_statuses
|
||||
state = raw_state.lower()
|
||||
|
||||
pending_values = {"pending", "queued", "running", "waiting"}
|
||||
failure_values = {"failure", "error", "failed"}
|
||||
success_values = {"success"}
|
||||
|
||||
if state in pending_values:
|
||||
print("pending")
|
||||
raise SystemExit(0)
|
||||
if state in failure_values:
|
||||
print("terminal-failure")
|
||||
raise SystemExit(0)
|
||||
if state in success_values:
|
||||
print("terminal-success")
|
||||
raise SystemExit(0)
|
||||
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
raw_value = item.get("status") or item.get("state")
|
||||
if not isinstance(raw_value, str) or not raw_value:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
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")
|
||||
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")
|
||||
elif not values:
|
||||
print("no-status")
|
||||
else:
|
||||
print("unknown")
|
||||
PY
|
||||
'
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 - <<'PY'
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -104,17 +110,61 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
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 ""
|
||||
if value in pending_values:
|
||||
found = True
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
if not found:
|
||||
print("[ci-queue-wait] no pending contexts")
|
||||
'
|
||||
}
|
||||
|
||||
record_cannot_assert() {
|
||||
local reason="$1"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": "CANNOT_ASSERT",
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" == "merge" ]]; then
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||
return 75
|
||||
fi
|
||||
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
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() {
|
||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-R|--repo)
|
||||
TARGET_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sha)
|
||||
HEAD_SHA="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||
echo "Error: --repo must be OWNER/REPO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||
record_cannot_assert "repository-owner-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||
record_cannot_assert "repository-name-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! detect_platform > /dev/null; then
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
|
||||
if [[ -n "$TARGET_REPO" ]]; then
|
||||
OWNER="${TARGET_REPO%%/*}"
|
||||
REPO="${TARGET_REPO##*/}"
|
||||
fi
|
||||
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||
record_cannot_assert "current-branch-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine remote host." >&2
|
||||
exit 1
|
||||
}
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||
record_cannot_assert "remote-host-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||
record_cannot_assert "credential-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
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
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -271,21 +459,24 @@ while true; do
|
||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
terminal-success)
|
||||
exit 0
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
fi
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
exit 3
|
||||
;;
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -25,15 +25,14 @@ Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$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" \
|
||||
--purpose merge \
|
||||
-B "$BASE_BRANCH" \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
is_known_tea_empty_identity_failure() {
|
||||
local error_file="$1"
|
||||
|
||||
python3 - "$error_file" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
error = handle.read()
|
||||
|
||||
known_empty_identity = re.search(
|
||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||
error,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
raise SystemExit(0 if known_empty_identity else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload='{"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)
|
||||
if [[ -n "$token" ]]; then
|
||||
@@ -202,7 +209,7 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
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)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -211,32 +218,9 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -109,7 +109,7 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,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"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||
data.get('head_ref'),
|
||||
head_ref,
|
||||
)
|
||||
head_sha = first_non_empty(
|
||||
nested(data, 'head', 'sha'),
|
||||
nested(data, 'head', 'id'),
|
||||
data.get('head_sha'),
|
||||
)
|
||||
head_repo = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'full_name'),
|
||||
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||
)
|
||||
if not head_repo:
|
||||
head_repo_owner = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||
nested(data, 'head', 'repo', 'owner_name'),
|
||||
)
|
||||
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||
if head_repo_owner and head_repo_name:
|
||||
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||
|
||||
base_ref = first_non_empty(
|
||||
nested(data, 'base', 'ref'),
|
||||
nested(data, 'base', 'name'),
|
||||
@@ -207,6 +226,8 @@ normalized = {
|
||||
'state': data.get('state'),
|
||||
'author': nested(data, 'user', 'login') or '',
|
||||
'headRefName': head_ref,
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# Covers:
|
||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,7 +62,7 @@ case "$mode" in
|
||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||
500) code=500; body='{"message":"internal server error"}' ;;
|
||||
no-status) code=200; body='{}' ;;
|
||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||
)
|
||||
}
|
||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
fail=1
|
||||
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
|
||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -eq 0 ]]; then
|
||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||
if [[ "$status_c" -ne 0 ]]; then
|
||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
|
||||
@@ -79,15 +79,24 @@ emit_response() {
|
||||
printf '200'
|
||||
fi
|
||||
}
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"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
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
POST_DATA="$post_data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = json.loads(os.environ["POST_DATA"])
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||
}, payload
|
||||
PY
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||
if [[ "$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"pr merge"* ]]; then
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
: > "$LOG_FILE"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -177,8 +154,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
|
||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --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
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --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
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
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"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.48",
|
||||
"version": "0.0.49",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-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"
|
||||
"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": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { verifyRegistry } from './gate-verify.mjs';
|
||||
|
||||
const root = process.cwd();
|
||||
const manifest = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8'));
|
||||
manifest.gateRoots = ['.mosaic-empty-gate-root'];
|
||||
manifest.criteria = [];
|
||||
manifest.gates = [];
|
||||
manifest.proseClaims = [];
|
||||
manifest.compatibilityScenarios = [];
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-empty-population-'));
|
||||
try {
|
||||
const manifestPath = path.join(directory, 'manifest.json');
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
structureOnly: true,
|
||||
fixtureProfile: false,
|
||||
});
|
||||
const required = ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios'];
|
||||
const missing = required.filter(
|
||||
(population) =>
|
||||
!result.failures.some((failure) =>
|
||||
failure.includes(`${population} population must be non-empty and anchored`),
|
||||
),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
process.stdout.write(`empty populations were not rejected: ${missing.join(', ')}\n`);
|
||||
process.exitCode = 0;
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'empty universally quantified registry populations rejected before evaluation\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertCurrentTreeObservation } from './gate-verify.mjs';
|
||||
|
||||
const prohibitedReports = [
|
||||
'HISTORY PROVENANCE VERIFIED local-ref',
|
||||
'COMMIT ANCESTRY VERIFIED origin/main',
|
||||
'PROVIDER LINEAGE SUCCESS pipeline-7',
|
||||
];
|
||||
const accepted = prohibitedReports.filter((report) => {
|
||||
try {
|
||||
assertCurrentTreeObservation(report);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const verifier = await readFile(path.join(process.cwd(), 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||
const stdoutWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length;
|
||||
const stderrWrites = [...verifier.matchAll(/process\.stderr\.write\s*\(/g)].length;
|
||||
const gatedErrorWrites = [...verifier.matchAll(/process\.stderr\.write\(`GATE VERIFY FAILED:/g)]
|
||||
.length;
|
||||
const currentTreeWriterCalls = [...verifier.matchAll(/writeCurrentTreeOutput\s*\(/g)].length;
|
||||
const productionRendererWired =
|
||||
stdoutWrites === 1 &&
|
||||
stderrWrites === 2 &&
|
||||
gatedErrorWrites === 2 &&
|
||||
currentTreeWriterCalls === 3 &&
|
||||
/function writeCurrentTreeOutput\(output\) \{\s*assertCurrentTreeObservation\(output\);\s*process\.stdout\.write/s.test(
|
||||
verifier,
|
||||
) &&
|
||||
/for \(const observation of observations\) \{\s*writeCurrentTreeOutput\(observation\);\s*\}/s.test(
|
||||
verifier,
|
||||
) &&
|
||||
/writeCurrentTreeOutput\(\s*`REGISTRY SUMMARY open behavior deltas:/s.test(verifier) &&
|
||||
!/\bconsole\.(?:log|info|debug|error|warn)\s*\(/.test(verifier);
|
||||
|
||||
if (accepted.length > 0 || !productionRendererWired) {
|
||||
process.stderr.write(
|
||||
`HISTORY_PROVENANCE_FORBIDDEN: closed current-tree observation renderer rejected=${prohibitedReports.length - accepted.length}/${prohibitedReports.length} production-wired=${productionRendererWired}\n`,
|
||||
);
|
||||
process.exit(79);
|
||||
}
|
||||
process.stdout.write('history provenance reporting capability is absent; owner RM-60\n');
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const boundary =
|
||||
"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.";
|
||||
const artifacts = [
|
||||
'gates/required-gates.baseline.json',
|
||||
'gates/gates.manifest.json',
|
||||
'docs/PRD.md',
|
||||
'docs/ADMIN-GUIDE/quality-gate-registry.md',
|
||||
'docs/DEVELOPER-GUIDE/quality-gate-registry.md',
|
||||
'docs/remediation/GATE-CLAIMS.md',
|
||||
'docs/plans/2026-08-01-rm-02-gate-registry.md',
|
||||
'docs/SITEMAP.md',
|
||||
];
|
||||
const forbidden = [
|
||||
/\bindependent\b[^\n.]{0,80}\bbaseline\b/i,
|
||||
/independently baselined/i,
|
||||
/independently anchored inventory/i,
|
||||
/protected (?:inventory )?(?:anchor|baseline)/i,
|
||||
/inventory (?:anchor|anchored)/i,
|
||||
];
|
||||
const failures = [];
|
||||
for (const relativePath of artifacts) {
|
||||
const contents = await readFile(path.join(process.cwd(), relativePath), 'utf8');
|
||||
const claims =
|
||||
relativePath === 'gates/gates.manifest.json'
|
||||
? JSON.parse(contents)
|
||||
.criteria.map((criterion) => criterion.currentText)
|
||||
.join('\n')
|
||||
: contents;
|
||||
if (!claims.includes(boundary)) failures.push(`${relativePath}: narrowed boundary is missing`);
|
||||
const overclaim = forbidden.find((pattern) => pattern.test(claims));
|
||||
if (overclaim) failures.push(`${relativePath}: inventory protection is overstated`);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
process.stderr.write(`INVENTORY_CLAIM_OVERSTATED: ${failure}\n`);
|
||||
}
|
||||
process.exit(84);
|
||||
}
|
||||
process.stdout.write('inventory drift boundary is stated in both directions; owner RM-60\n');
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const root = process.cwd();
|
||||
const removedGateId = 'hook-pre-push';
|
||||
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||
|
||||
function shrinkManifest(manifest) {
|
||||
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||
assert.ok(removedGate);
|
||||
const removedCaseRefs = new Set(
|
||||
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||
);
|
||||
const removedCriterionIds = new Set(
|
||||
manifest.criteria
|
||||
.filter(
|
||||
(criterion) =>
|
||||
criterion.caseRefs.length > 0 &&
|
||||
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||
)
|
||||
.map((criterion) => criterion.id),
|
||||
);
|
||||
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||
manifest.criteria = manifest.criteria
|
||||
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||
.map((criterion) => ({
|
||||
...criterion,
|
||||
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
...(criterion.gateRefs
|
||||
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||
: {}),
|
||||
}));
|
||||
manifest.proseClaims = manifest.proseClaims.filter(
|
||||
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||
);
|
||||
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||
.map((scenario) => ({
|
||||
...scenario,
|
||||
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
}))
|
||||
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function attack(mode) {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), `gate-inventory-${mode}-`));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const source = await readFile(path.join(root, 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||
if (source.split(inventoryEntry).length - 1 !== 1) {
|
||||
throw new Error('source inventory fixture drifted');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
mode === 'source-manifest' ? source.replace(inventoryEntry, '') : source,
|
||||
);
|
||||
const baseline = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||
);
|
||||
if (mode === 'baseline-manifest') {
|
||||
baseline.gates = baseline.gates.filter((gate) => gate.id !== removedGateId);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
`${JSON.stringify(baseline)}\n`,
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
shrinkManifest(manifest);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
return (
|
||||
result.status !== 0 &&
|
||||
new RegExp(`(?:baseline|verifier inventory).*${removedGateId}`, 'i').test(combined)
|
||||
);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const sourceManifestRejected = await attack('source-manifest');
|
||||
const baselineManifestRejected = await attack('baseline-manifest');
|
||||
if (sourceManifestRejected && baselineManifestRejected) {
|
||||
process.stderr.write(
|
||||
'INVENTORY_SHRINK_REJECTED: source+manifest and baseline+manifest shrink attacks detected\n',
|
||||
);
|
||||
process.exit(83);
|
||||
}
|
||||
process.stdout.write(
|
||||
`inventory shrink attack escaped: source-manifest=${sourceManifestRejected} baseline-manifest=${baselineManifestRejected}\n`,
|
||||
);
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { verifyRegistry } from './gate-verify.mjs';
|
||||
|
||||
const mode = process.argv[2];
|
||||
const root = process.cwd();
|
||||
const source = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8'));
|
||||
const expectedGateIds = source.gates.map((gate) => gate.id);
|
||||
if (expectedGateIds.length === 0) {
|
||||
process.stderr.write('gate population control requires a non-empty anchored inventory\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
async function rejectedForEveryGate(mutate, diagnostic) {
|
||||
for (const gateId of expectedGateIds) {
|
||||
const manifest = structuredClone(source);
|
||||
const gate = manifest.gates.find((candidate) => candidate.id === gateId);
|
||||
mutate(gate);
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-population-control-'));
|
||||
const manifestPath = path.join(directory, 'manifest.json');
|
||||
try {
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
structureOnly: true,
|
||||
fixtureProfile: false,
|
||||
});
|
||||
if (!result.failures.some((failure) => diagnostic(failure, gateId))) return false;
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let rejected;
|
||||
if (mode === 'evidence-subject') {
|
||||
rejected = true;
|
||||
for (const gateId of expectedGateIds) {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-evidence-consumption-'));
|
||||
try {
|
||||
await mkdir(path.join(directory, 'gates'), { recursive: true });
|
||||
const probe = path.join(directory, 'gates', 'probe.sh');
|
||||
await writeFile(probe, '#!/bin/sh\necho EVIDENCE_PROBE >&2\nexit 7\n');
|
||||
await chmod(probe, 0o755);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
gateRoots: ['gates'],
|
||||
governingClaimFiles: [],
|
||||
coverageBoundary: { included: ['evidence fixture'], excluded: [], trackedBy: 'RM-02' },
|
||||
criteria: [
|
||||
{
|
||||
id: 'EVIDENCE-CONSUMPTION',
|
||||
originalText: 'Consumed evidence stays bound to its gate.',
|
||||
currentText: 'Consumed evidence stays bound to its gate.',
|
||||
claimType: 'integrity',
|
||||
source: 'gate-population-control',
|
||||
meaningChanges: [],
|
||||
caseRefs: [`${gateId}/probe`],
|
||||
},
|
||||
],
|
||||
proseClaims: [],
|
||||
compatibilityScenarios: [],
|
||||
gates: [
|
||||
{
|
||||
id: gateId,
|
||||
source: 'gates/probe.sh',
|
||||
invocation: ['gates/probe.sh'],
|
||||
deployment: { kind: 'none', reason: 'population fixture' },
|
||||
inertMutation: {
|
||||
file: 'gates/probe.sh',
|
||||
find: 'exit 7',
|
||||
replace: 'exit 0',
|
||||
caseId: 'probe',
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: 'probe',
|
||||
criterionIds: ['EVIDENCE-CONSUMPTION'],
|
||||
mustFail: true,
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'different-gate-subject' },
|
||||
reasonPattern: 'EVIDENCE_PROBE',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const manifestPath = path.join(directory, 'gates', 'gates.manifest.json');
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root: directory,
|
||||
manifest: manifestPath,
|
||||
structureOnly: false,
|
||||
fixtureProfile: true,
|
||||
});
|
||||
if (
|
||||
!result.failures.some(
|
||||
(failure) =>
|
||||
failure.includes(`gate ${gateId}: consumed evidence subject`) &&
|
||||
failure.includes('does not match gate definition'),
|
||||
)
|
||||
) {
|
||||
rejected = false;
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} else if (mode === 'type-strict') {
|
||||
rejected = await rejectedForEveryGate(
|
||||
(gate) => {
|
||||
gate.cases[0].actual.exitCode = '0';
|
||||
},
|
||||
(failure, gateId) =>
|
||||
failure.includes(
|
||||
`${gateId}/${source.gates.find((gate) => gate.id === gateId).cases[0].id}.actual.exitCode`,
|
||||
) && failure.includes('expected an integer'),
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`unknown gate population control ${String(mode)}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!rejected) {
|
||||
process.stdout.write(`${mode} population control did not reject every registered gate\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${mode} population control rejected every registered gate\n`);
|
||||
process.exit(1);
|
||||
@@ -1,283 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const verifierPath = path.join(root, 'scripts', 'gate-verify.mjs');
|
||||
const manifestPath = path.join(root, 'gates', 'gates.manifest.json');
|
||||
const requiredGateId = 'hook-pre-push';
|
||||
|
||||
function output(result) {
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
|
||||
function shrinkManifest(manifest, removedGateId) {
|
||||
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||
assert.ok(removedGate, `fixture gate ${removedGateId} must exist`);
|
||||
const removedCaseRefs = new Set(
|
||||
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||
);
|
||||
const removedCriterionIds = new Set(
|
||||
manifest.criteria
|
||||
.filter(
|
||||
(criterion) =>
|
||||
criterion.caseRefs.length > 0 &&
|
||||
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||
)
|
||||
.map((criterion) => criterion.id),
|
||||
);
|
||||
|
||||
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||
manifest.criteria = manifest.criteria
|
||||
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||
.map((criterion) => ({
|
||||
...criterion,
|
||||
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
...(criterion.gateRefs
|
||||
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||
: {}),
|
||||
}));
|
||||
manifest.proseClaims = manifest.proseClaims.filter(
|
||||
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||
);
|
||||
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||
.map((scenario) => ({
|
||||
...scenario,
|
||||
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
}))
|
||||
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('shrinking the verifier inventory and manifest together is rejected by a same-checkout baseline', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-both-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||
assert.equal(verifier.split(inventoryEntry).length - 1, 1, 'source inventory fixture drifted');
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(inventoryEntry, ''),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
shrinkManifest(manifest, requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0, 'shrinking source anchor and manifest together must go red');
|
||||
assert.match(output(result), /same-checkout required-gate baseline.*hook-pre-push/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shrinking the same-checkout baseline and manifest together is rejected by verifier inventory', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-baseline-manifest-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
await copyFile(verifierPath, path.join(fixture, 'scripts', 'gate-verify.mjs'));
|
||||
const baseline = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||
);
|
||||
baseline.gates = baseline.gates.filter((gate) => gate.id !== requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
`${JSON.stringify(baseline)}\n`,
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
shrinkManifest(manifest, requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0, 'shrinking baseline and manifest together must go red');
|
||||
assert.match(output(result), /verifier inventory.*hook-pre-push.*baseline/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('every gate carries an evidence-side subject distinct from its definition', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
for (const gate of manifest.gates) {
|
||||
assert.ok(gate.cases.length > 0, `${gate.id} must have consumable evidence`);
|
||||
for (const gateCase of gate.cases) {
|
||||
assert.equal(
|
||||
gateCase.evidence?.subject,
|
||||
gate.id,
|
||||
`${gate.id}/${gateCase.id} must source its subject from the evidence record`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('inventory claim control rejects qualified independence wording', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-inventory-overclaim-'));
|
||||
const artifacts = [
|
||||
'gates/required-gates.baseline.json',
|
||||
'gates/gates.manifest.json',
|
||||
'docs/PRD.md',
|
||||
'docs/ADMIN-GUIDE/quality-gate-registry.md',
|
||||
'docs/DEVELOPER-GUIDE/quality-gate-registry.md',
|
||||
'docs/remediation/GATE-CLAIMS.md',
|
||||
'docs/plans/2026-08-01-rm-02-gate-registry.md',
|
||||
'docs/SITEMAP.md',
|
||||
];
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-inventory-claim-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs'),
|
||||
);
|
||||
for (const relativePath of artifacts) {
|
||||
const target = path.join(fixture, relativePath);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await copyFile(path.join(root, relativePath), target);
|
||||
}
|
||||
const prd = path.join(fixture, 'docs', 'PRD.md');
|
||||
await writeFile(
|
||||
prd,
|
||||
`${await readFile(prd, 'utf8')}\nThis provides an independent seven-gate baseline comparison.\n`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs')],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 84, output(result));
|
||||
assert.match(output(result), /INVENTORY_CLAIM_OVERSTATED.*docs\/PRD\.md/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence population control depends on production result consumption wiring', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-evidence-consumer-inert-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
const consumer = ` const subjectFailure = consumeEvidenceSubject(gate, result.evidence);\n if (subjectFailure) failures.push(subjectFailure);\n`;
|
||||
assert.equal(verifier.split(consumer).length - 1, 1, 'consumer fixture drifted');
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(consumer, ''),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-population-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-population-control.mjs'),
|
||||
);
|
||||
await copyFile(manifestPath, path.join(fixture, 'gates', 'gates.manifest.json'));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-population-control.mjs'), 'evidence-subject'],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /did not reject every registered gate/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('production verification has a closed current-tree observation renderer', async () => {
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
assert.doesNotMatch(verifier, /from ['"]\.\/gate-history\.mjs['"]/);
|
||||
assert.doesNotMatch(verifier, /\bverifyHistory\s*\(/);
|
||||
assert.doesNotMatch(verifier, /history[-_ ]?provenance/i);
|
||||
const attacks = [
|
||||
[
|
||||
'history wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^HISTORY PROVENANCE VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
[
|
||||
'renamed ancestry wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
[
|
||||
'provider lineage wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^PROVIDER LINEAGE SUCCESS /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
['writer assertion bypass', ' assertCurrentTreeObservation(output);\n', ''],
|
||||
[
|
||||
'final success stdout bypass',
|
||||
' writeCurrentTreeOutput(\n `REGISTRY SUMMARY open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain`,\n );',
|
||||
" process.stdout.write('COMMIT ANCESTRY VERIFIED\\n');",
|
||||
],
|
||||
[
|
||||
'direct success stderr bypass',
|
||||
" const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;",
|
||||
" process.stderr.write('PROVIDER LINEAGE SUCCESS\\n');\n const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;",
|
||||
],
|
||||
];
|
||||
for (const [name, find, replace] of attacks) {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-history-renderer-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
assert.equal(verifier.split(find).length - 1, 1, `${name}: fixture drifted`);
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(find, replace),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs')],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 79, `${name}: ${output(result)}`);
|
||||
assert.match(output(result), /HISTORY_PROVENANCE_FORBIDDEN/);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,785 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, copyFile, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs');
|
||||
const fixtureRunner = path.join(
|
||||
process.cwd(),
|
||||
'scripts',
|
||||
'test-support',
|
||||
'gate-verify-fixture-runner.mjs',
|
||||
);
|
||||
const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`);
|
||||
|
||||
async function fixture(name = 'case') {
|
||||
const root = path.join(fixtureBase, name);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function baseManifest() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
gateRoots: ['gates'],
|
||||
governingClaimFiles: [],
|
||||
coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' },
|
||||
criteria: [
|
||||
{
|
||||
id: 'META-CRIT-1',
|
||||
originalText: 'The fixture rejects its bad input.',
|
||||
currentText: 'The fixture rejects its bad input.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
},
|
||||
],
|
||||
compatibilityScenarios: [],
|
||||
proseClaims: [],
|
||||
gates: [
|
||||
{
|
||||
id: 'meta-fixture',
|
||||
source: 'gates/meta-fixture.sh',
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
deployment: { kind: 'none', reason: 'test fixture only' },
|
||||
inertMutation: {
|
||||
file: 'gates/meta-fixture.sh',
|
||||
find: 'exit 7',
|
||||
replace: 'exit 0',
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: 'rejects-bad-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: true,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'meta-fixture' },
|
||||
reasonPattern: 'META_REJECT',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function writeGate(root, contents = '#!/bin/sh\necho META_REJECT >&2\nexit 7\n') {
|
||||
const target = path.join(root, 'gates', 'meta-fixture.sh');
|
||||
await writeFile(target, contents);
|
||||
await chmod(target, 0o755);
|
||||
}
|
||||
|
||||
async function writeManifest(root, manifest) {
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), `${JSON.stringify(manifest)}\n`);
|
||||
}
|
||||
|
||||
function verify(root, extraArgs = []) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[fixtureRunner, '--root', root, '--manifest', 'gates/gates.manifest.json', ...extraArgs],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
}
|
||||
|
||||
function verifyProductionStructure(root, extraArgs = []) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
verifier,
|
||||
'--root',
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
...extraArgs,
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
}
|
||||
|
||||
function output(result) {
|
||||
return `${result.stdout}\n${result.stderr}`;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('universally quantified registry checks reject empty populations before evaluation', async () => {
|
||||
const root = await fixture('empty-registry-populations');
|
||||
await mkdir(path.join(root, 'empty-gate-root'), { recursive: true });
|
||||
const manifest = baseManifest();
|
||||
manifest.gateRoots = ['empty-gate-root'];
|
||||
manifest.criteria = [];
|
||||
manifest.proseClaims = [];
|
||||
manifest.compatibilityScenarios = [];
|
||||
manifest.gates = [];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /criteria population.*non-empty.*anchored/i);
|
||||
assert.match(output(result), /gates population.*non-empty.*anchored/i);
|
||||
assert.match(output(result), /proseClaims population.*non-empty.*anchored/i);
|
||||
assert.match(output(result), /compatibilityScenarios population.*non-empty.*anchored/i);
|
||||
});
|
||||
|
||||
test('production verifier exposes no fixture-profile population bypass', async () => {
|
||||
const root = await fixture('no-production-fixture-profile');
|
||||
const result = verifyProductionStructure(root, ['--fixture-profile']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unknown option: --fixture-profile/i);
|
||||
});
|
||||
|
||||
test('anchored gate inventory and population criteria cannot shrink together', async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture('shrunken-gate-population');
|
||||
await copyFile(
|
||||
path.join(process.cwd(), 'gates', 'required-gates.baseline.json'),
|
||||
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||
);
|
||||
source.gates = source.gates.filter((gate) => gate.id !== 'hook-pre-push');
|
||||
for (const criterion of source.criteria) {
|
||||
if (criterion.gateRefs) {
|
||||
criterion.gateRefs = criterion.gateRefs.filter((gateId) => gateId !== 'hook-pre-push');
|
||||
}
|
||||
criterion.caseRefs = criterion.caseRefs.filter(
|
||||
(caseRef) => !caseRef.startsWith('hook-pre-push/'),
|
||||
);
|
||||
}
|
||||
await writeManifest(root, source);
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(
|
||||
output(result),
|
||||
/same-checkout required-gate baseline rejects manifest drift at hook-pre-push/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('general population criteria cannot delete their gateRefs binding', async () => {
|
||||
const requiredCriteria = [
|
||||
'RM02-EVIDENCE-SUBJECT-BINDING',
|
||||
'RM02-TYPE-STRICT-SCHEMA',
|
||||
'RM02-NONEMPTY-ANCHORED-QUANTIFICATION',
|
||||
];
|
||||
for (const criterionId of requiredCriteria) {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture(`missing-gate-refs-${criterionId}`);
|
||||
delete source.criteria.find((criterion) => criterion.id === criterionId).gateRefs;
|
||||
await writeManifest(root, source);
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(
|
||||
output(result),
|
||||
new RegExp(`${criterionId}.*gateRefs.*required`, 'i'),
|
||||
criterionId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('general population criteria must span every registered gate', async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture('incomplete-gate-refs');
|
||||
source.criteria.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING').gateRefs =
|
||||
source.criteria
|
||||
.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING')
|
||||
.gateRefs.filter((gateId) => gateId !== 'quality-lint');
|
||||
await writeManifest(root, source);
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(
|
||||
output(result),
|
||||
/RM02-EVIDENCE-SUBJECT-BINDING.*gate population binding is missing quality-lint/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('an externally inerted failure branch makes verification nonzero and names the gate', async () => {
|
||||
const root = await fixture('external-inert');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture/);
|
||||
});
|
||||
|
||||
test('the verifier applies the declared inert mutation and observes its own control red', async () => {
|
||||
const root = await fixture('internal-meta');
|
||||
await writeGate(root);
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /META-NEGATIVE-CONTROL.*meta-fixture.*observed red/i);
|
||||
});
|
||||
|
||||
test('a mutation crash is rejected instead of counted as an observed-red control', async () => {
|
||||
const root = await fixture('mutation-crash');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.replace = 'this is not valid shell (';
|
||||
manifest.gates[0].inertMutation.expected = { exitCode: 0 };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*mutation.*unexpected outcome/i);
|
||||
assert.doesNotMatch(output(result), /META-NEGATIVE-CONTROL.*observed red/i);
|
||||
});
|
||||
|
||||
test('a stale declared mutation case id is rejected instead of falling back', async () => {
|
||||
const root = await fixture('stale-case-id');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.caseId = 'case-that-does-not-exist';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*case-that-does-not-exist.*not found/i);
|
||||
});
|
||||
|
||||
test('duplicate stable ids and unsupported schema versions are rejected', async () => {
|
||||
const root = await fixture('closed-schema');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.schemaVersion = 99;
|
||||
manifest.criteria.push({ ...manifest.criteria[0] });
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unsupported schemaVersion 99/i);
|
||||
assert.match(output(result), /duplicate criterion id META-CRIT-1/i);
|
||||
});
|
||||
|
||||
test('misspelled nested assertion fields are rejected instead of becoming optional', async () => {
|
||||
const root = await fixture('nested-schema-typo');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required.outputPatern = 'META_REJECT';
|
||||
manifest.gates[0].cases[0].actual.outputPatern = 'META_REJECT';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /required.*unknown field outputPatern/i);
|
||||
assert.match(output(result), /actual.*unknown field outputPatern/i);
|
||||
});
|
||||
|
||||
test('present outcome patterns cannot be empty assertion bypasses', async () => {
|
||||
const root = await fixture('nested-schema-empty-patterns');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required.outputPattern = '';
|
||||
manifest.gates[0].cases[0].actual.notOutputPattern = ' ';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /required.outputPattern: expected a non-empty pattern/i);
|
||||
assert.match(output(result), /actual.notOutputPattern: expected a non-empty pattern/i);
|
||||
});
|
||||
|
||||
test('nested discriminator and comparison fields reject wrong types', async () => {
|
||||
const root = await fixture('nested-schema-types');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].mustFail = 'true';
|
||||
manifest.gates[0].cases[0].required.exitCode = '7';
|
||||
manifest.gates[0].cases[0].actual.outputPattern = 7;
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /mustFail: expected a boolean/i);
|
||||
assert.match(output(result), /required.exitCode: expected an integer/i);
|
||||
assert.match(output(result), /actual.outputPattern: expected a string/i);
|
||||
});
|
||||
|
||||
test('recursive closed-schema guards reject unknown fields in every nested assertion object', async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const checkout = source.gates.find((gate) => gate.id === 'checkout-preflight');
|
||||
const stale = checkout.cases.find((gateCase) => gateCase.id === 'stale-build-lock');
|
||||
const queue = source.gates.find((gate) => gate.id === 'ci-queue-wait');
|
||||
const queueCase = queue.cases.find((gateCase) => gateCase.id === 'terminal-success');
|
||||
const targets = [
|
||||
['coverageBoundary', (manifest) => manifest.coverageBoundary],
|
||||
['mergeAssertions', (manifest) => manifest.mergeAssertions],
|
||||
[
|
||||
'meaningChanges',
|
||||
(manifest) =>
|
||||
manifest.criteria.find((criterion) => criterion.meaningChanges.length).meaningChanges[0],
|
||||
],
|
||||
['proseClaims', (manifest) => manifest.proseClaims[0]],
|
||||
['compatibility expected', (manifest) => manifest.compatibilityScenarios[0].expected],
|
||||
[
|
||||
'deployment',
|
||||
(manifest) => manifest.gates.find((gate) => gate.id === 'ci-queue-wait').deployment,
|
||||
],
|
||||
['inertMutation', (manifest) => manifest.gates[0].inertMutation],
|
||||
['inert expected', (manifest) => manifest.gates[0].inertMutation.expected],
|
||||
['required', (manifest) => manifest.gates[0].cases[0].required],
|
||||
['actual', (manifest) => manifest.gates[0].cases[0].actual],
|
||||
[
|
||||
'fixture',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'checkout-preflight')
|
||||
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture,
|
||||
],
|
||||
[
|
||||
'write entry',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'checkout-preflight')
|
||||
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture.writeFiles[0],
|
||||
],
|
||||
[
|
||||
'replace entry',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'checkout-preflight')
|
||||
.cases.find((gateCase) => gateCase.id === 'criterion-misbinding').fixture.replaceFiles[0],
|
||||
],
|
||||
[
|
||||
'defect',
|
||||
(manifest) =>
|
||||
manifest.gates
|
||||
.find((gate) => gate.id === 'ci-queue-wait')
|
||||
.cases.find((gateCase) => gateCase.id === 'terminal-success').defect,
|
||||
],
|
||||
];
|
||||
assert.ok(stale.fixture && queueCase.defect);
|
||||
for (const [name, select] of targets) {
|
||||
const root = await fixture(`recursive-${name.replaceAll(' ', '-')}`);
|
||||
const manifest = structuredClone(source);
|
||||
select(manifest).unexpectedNestedField = true;
|
||||
await writeManifest(root, manifest);
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0, `${name}: ${output(result)}`);
|
||||
assert.match(output(result), /unknown field unexpectedNestedField/i, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest-controlled fixture paths cannot escape the sandbox', async () => {
|
||||
const root = await fixture('path-traversal');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: '../../escaped-by-manifest', content: 'bad' }],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /path escapes sandbox/i);
|
||||
});
|
||||
|
||||
test('fixture writes reject a final symlink and preserve its outside target', async () => {
|
||||
const root = await fixture('final-symlink');
|
||||
await writeGate(root);
|
||||
const outside = path.join(fixtureBase, 'outside-sentinel');
|
||||
await writeFile(outside, 'preserve-me\n');
|
||||
const linked = path.join(root, 'linked-sentinel');
|
||||
await symlink(outside, linked);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: 'linked-sentinel', content: 'overwritten\n' }],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /fixture write.*symbolic link/i);
|
||||
assert.equal(await readFile(outside, 'utf8'), 'preserve-me\n');
|
||||
});
|
||||
|
||||
test('an executable below a gate root without an entry is rejected', async () => {
|
||||
const root = await fixture('unregistered');
|
||||
await writeGate(root);
|
||||
const extra = path.join(root, 'gates', 'forgotten.sh');
|
||||
await writeFile(extra, '#!/bin/sh\nexit 1\n');
|
||||
await chmod(extra, 0o755);
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unregistered gate.*forgotten\.sh/i);
|
||||
});
|
||||
|
||||
test('a must-fail case without a reason diagnostic is rejected', async () => {
|
||||
const root = await fixture('missing-reason');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].reasonPattern = '';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*rejects-bad-input.*reasonPattern/i);
|
||||
});
|
||||
|
||||
test('a gate with zero must-fail cases is rejected', async () => {
|
||||
const root = await fixture('no-negative');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation = {
|
||||
file: 'gates/meta-fixture.sh',
|
||||
find: 'exit 0',
|
||||
replace: 'exit 1',
|
||||
};
|
||||
manifest.gates[0].cases = [
|
||||
{
|
||||
id: 'positive',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: false,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 0 },
|
||||
actual: { exitCode: 0 },
|
||||
reasonPattern: '',
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*no negative control/i);
|
||||
});
|
||||
|
||||
test('reordered but equivalent outcome fields do not create a false behavior delta', async () => {
|
||||
const root = await fixture('reordered-outcomes');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required = { exitCode: 7, outputPattern: 'META_REJECT' };
|
||||
manifest.gates[0].cases[0].actual = { outputPattern: 'META_REJECT', exitCode: 7 };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.doesNotMatch(output(result), /behavior delta requires|DEFECT \(owner:/);
|
||||
});
|
||||
|
||||
test('a required-versus-actual delta without a tracked owner is rejected', async () => {
|
||||
const root = await fixture('ownerless-delta');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.find = 'exit 0';
|
||||
manifest.gates[0].inertMutation.replace = 'exit 7';
|
||||
manifest.gates[0].cases[0].actual.exitCode = 0;
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*delta.*tracked owner/i);
|
||||
});
|
||||
|
||||
test('moving criterion bindings to unrelated cases is rejected', async () => {
|
||||
const root = await fixture('semantic-misbinding');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'META-CRIT-2',
|
||||
originalText: 'The fixture reports the second rejection reason.',
|
||||
currentText: 'The fixture reports the second rejection reason.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-second-input'],
|
||||
});
|
||||
manifest.gates[0].cases.push({
|
||||
...manifest.gates[0].cases[0],
|
||||
id: 'rejects-second-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
});
|
||||
manifest.gates[0].cases[0].criterionIds = ['META-CRIT-2'];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /META-CRIT-1.*rejects-bad-input.*not bound/i);
|
||||
assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i);
|
||||
});
|
||||
|
||||
test('canonical verification preserves binding diagnostics when a case fixture is also stale', async () => {
|
||||
const root = await fixture('misbinding-plus-stale-fixture');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'META-CRIT-2',
|
||||
originalText: 'The second case rejects its own bad input.',
|
||||
currentText: 'The second case rejects its own bad input.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-second-input'],
|
||||
});
|
||||
manifest.gates[0].cases.push({
|
||||
...manifest.gates[0].cases[0],
|
||||
id: 'rejects-second-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
});
|
||||
manifest.gates[0].cases[0].criterionIds = ['META-CRIT-2'];
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
replaceFiles: [
|
||||
{
|
||||
path: 'gates/meta-fixture.sh',
|
||||
find: 'text that is not present',
|
||||
replace: 'irrelevant',
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /META-CRIT-1.*rejects-bad-input.*not bound/i);
|
||||
assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i);
|
||||
assert.match(output(result), /fixture replace.*stale or ambiguous/i);
|
||||
});
|
||||
|
||||
test('moving meaning and prose criteria to an unrelated type error is rejected', async () => {
|
||||
const root = await fixture('real-manifest-misbinding');
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(id) => !['RM02-MEANING-PROVENANCE', 'RM02-PROSE-CONTROL'].includes(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
const typeError = manifest.gates
|
||||
.find((gate) => gate.id === 'quality-typecheck')
|
||||
.cases.find((gateCase) => gateCase.id === 'type-error');
|
||||
typeError.criterionIds.push('RM02-MEANING-PROVENANCE', 'RM02-PROSE-CONTROL');
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root, ['--structure-only']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /RM02-MEANING-PROVENANCE.*missing-meaning-provenance.*not bound/i);
|
||||
assert.match(output(result), /RM02-PROSE-CONTROL.*prose-claim-misbinding.*not bound/i);
|
||||
assert.match(
|
||||
output(result),
|
||||
/RM02-(?:MEANING-PROVENANCE|PROSE-CONTROL).*undeclared exercising case quality-typecheck\/type-error/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('a criterion with no bound case is rejected', async () => {
|
||||
const root = await fixture('unbound-criterion');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'ORPHAN',
|
||||
originalText: 'This criterion is not exercised.',
|
||||
currentText: 'This criterion is not exercised.',
|
||||
claimType: 'quality',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
});
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /ORPHAN.*no bound case/i);
|
||||
});
|
||||
|
||||
test('an unbound governing prose marker is rejected', async () => {
|
||||
const root = await fixture('unbound-prose');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'docs'), { recursive: true });
|
||||
await writeFile(path.join(root, 'docs', 'governing.md'), 'GATE-CLAIM:UNBOUND\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.governingClaimFiles = ['docs/governing.md'];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /GATE-CLAIM:UNBOUND.*unbound/i);
|
||||
});
|
||||
|
||||
test('directly contradictory modeled scenarios are rejected', async () => {
|
||||
const root = await fixture('conflict');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.compatibilityScenarios = [
|
||||
{
|
||||
id: 'ONE',
|
||||
construction: 'same-input',
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
invocation: ['sh', '-c', 'exit 0'],
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
{
|
||||
id: 'TWO',
|
||||
construction: 'same-input',
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
invocation: ['sh', '-c', 'exit 1'],
|
||||
expected: { exitCode: 1 },
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /ONE.*TWO.*conflict/i);
|
||||
});
|
||||
|
||||
test('compatibility scenarios execute referenced conditions as one construction', async () => {
|
||||
const root = await fixture('combined-compatibility');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases.push({
|
||||
id: 'second-condition',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: true,
|
||||
invocation: ['sh', '-c', 'echo "$SECOND_REASON" >&2; exit 7'],
|
||||
fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] },
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'meta-fixture' },
|
||||
reasonPattern: 'SECOND_REASON',
|
||||
environment: { SECOND_REASON: 'SECOND_REASON' },
|
||||
});
|
||||
manifest.criteria[0].caseRefs.push('meta-fixture/second-condition');
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: 'conditions/first', content: 'present\n' }],
|
||||
};
|
||||
manifest.compatibilityScenarios = [
|
||||
{
|
||||
id: 'BOTH-CONDITIONS',
|
||||
construction: 'both-fixtures',
|
||||
caseRefs: ['meta-fixture/rejects-bad-input', 'meta-fixture/second-condition'],
|
||||
invocation: ['sh', '-c', 'test -f conditions/first && test -f conditions/second'],
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /COMPATIBILITY BOTH-CONDITIONS: observed expected outcome/i);
|
||||
});
|
||||
|
||||
test('a restated criterion without provenance is rejected', async () => {
|
||||
const root = await fixture('provenance');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria[0].currentText = 'The fixture rejects only malformed input.';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /META-CRIT-1.*meaning-change provenance/i);
|
||||
});
|
||||
|
||||
test('a security criterion bound only to a positive case is unregistered in substance', async () => {
|
||||
const root = await fixture('positive-only-criterion');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.criteria.push({
|
||||
id: 'POSITIVE-ONLY',
|
||||
originalText: 'A security property.',
|
||||
currentText: 'A security property.',
|
||||
claimType: 'security',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
});
|
||||
manifest.gates[0].cases.push({
|
||||
id: 'positive-only',
|
||||
criterionIds: ['POSITIVE-ONLY'],
|
||||
mustFail: false,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
reasonPattern: '',
|
||||
});
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /POSITIVE-ONLY.*no must-fail case/i);
|
||||
});
|
||||
|
||||
test('a registered prose claim whose marker is absent is rejected', async () => {
|
||||
const root = await fixture('missing-prose-marker');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'docs'), { recursive: true });
|
||||
await writeFile(path.join(root, 'docs', 'governing.md'), 'No marker here.\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.governingClaimFiles = ['docs/governing.md'];
|
||||
manifest.proseClaims = [{ id: 'MISSING-MARKER', criterionId: 'META-CRIT-1' }];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /GATE-CLAIM:MISSING-MARKER.*missing/i);
|
||||
});
|
||||
|
||||
test('source-versus-deployed byte drift is rejected and names the gate', async () => {
|
||||
const root = await fixture('deployment-drift');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'deployed'), { recursive: true });
|
||||
await writeFile(path.join(root, 'deployed', 'meta-fixture.sh'), '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*source.*deployed.*drift/i);
|
||||
});
|
||||
|
||||
test('deployment drift meta-control fails if the shared comparator is made inert', async () => {
|
||||
const root = await fixture('deployment-comparator-inert');
|
||||
await writeGate(root);
|
||||
await mkdir(path.join(root, 'deployed'), { recursive: true });
|
||||
await copyFile(
|
||||
path.join(root, 'gates', 'meta-fixture.sh'),
|
||||
path.join(root, 'deployed', 'meta-fixture.sh'),
|
||||
);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const alteredScripts = path.join(root, 'verifier-scripts');
|
||||
await mkdir(alteredScripts, { recursive: true });
|
||||
const verifierSource = await readFile(verifier, 'utf8');
|
||||
const inertSource = verifierSource.replace(
|
||||
'return source.equals(deployed);',
|
||||
'return true; // deliberate test-only inert comparator',
|
||||
);
|
||||
assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale');
|
||||
await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(alteredScripts, 'gate-verify.mjs'),
|
||||
'--root',
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*drift negative control was ineffective/i);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const expectedTriggers = `when:
|
||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||
# protection requires no push/ci status context; main still gets full push CI).
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: main`;
|
||||
const expectedGateStep = ` image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm gate:verify
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard`;
|
||||
|
||||
export function assertUnprivilegedGateStep(pipeline) {
|
||||
assert.doesNotMatch(
|
||||
pipeline,
|
||||
/privileged/i,
|
||||
'no pull-request pipeline step may declare privilege',
|
||||
);
|
||||
for (const line of pipeline.split('\n')) {
|
||||
const candidate = line.trimStart().replace(/^-\s+/, '');
|
||||
if (/^(?:["'!<].*|[A-Za-z_][A-Za-z0-9_-]*\s+):(?:\s|$)/.test(candidate)) {
|
||||
assert.fail(`non-canonical or merged YAML key is forbidden: ${candidate}`);
|
||||
}
|
||||
}
|
||||
const triggerMatches = [...pipeline.matchAll(/^when:\n([\s\S]*?)(?=\n\n)/gm)];
|
||||
assert.equal(triggerMatches.length, 1, 'exactly one top-level trigger is required');
|
||||
assert.equal(
|
||||
`when:\n${triggerMatches[0][1].trimEnd()}`,
|
||||
expectedTriggers,
|
||||
'top-level triggers must match closed PR/main construction',
|
||||
);
|
||||
|
||||
const matches = [
|
||||
...pipeline.matchAll(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:|$)/g),
|
||||
];
|
||||
assert.equal(matches.length, 1, 'exactly one gate-verify step is required');
|
||||
// Closed textual construction by design: accepting arbitrary YAML syntax here
|
||||
// would require a duplicate-key-preserving parser. Exact equality rejects all
|
||||
// extra keys, quoted/escaped key spellings, aliases, and mapping merges.
|
||||
assert.equal(
|
||||
matches[0][1].trimEnd(),
|
||||
expectedGateStep,
|
||||
'gate-verify step must match closed unprivileged construction',
|
||||
);
|
||||
}
|
||||
|
||||
test('package.json exposes the canonical gate:verify command', async () => {
|
||||
const packageJson = JSON.parse(await readFile(`${root}/package.json`, 'utf8'));
|
||||
assert.equal(packageJson.scripts['gate:verify'], 'node scripts/gate-verify.mjs');
|
||||
});
|
||||
|
||||
test('Woodpecker runs the closed unprivileged gate construction on every pipeline', async () => {
|
||||
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
|
||||
assertUnprivilegedGateStep(pipeline);
|
||||
});
|
||||
|
||||
test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger narrowing', async () => {
|
||||
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
|
||||
const additions = [
|
||||
' privileged: *enabled\n',
|
||||
' "privileged": true\n',
|
||||
" 'privileged': true\n",
|
||||
' privileged : true\n',
|
||||
' "priv\\u0069leged": true\n',
|
||||
' <<: *privileged-step\n',
|
||||
' "<<": *privileged-step\n',
|
||||
];
|
||||
for (const addition of additions) {
|
||||
const changed = pipeline.replace(
|
||||
' gate-verify:\n image:',
|
||||
` gate-verify:\n${addition} image:`,
|
||||
);
|
||||
assert.throws(() => assertUnprivilegedGateStep(changed));
|
||||
}
|
||||
const privilegedInstall = pipeline.replace(
|
||||
' install:\n image:',
|
||||
' install:\n privileged: true\n image:',
|
||||
);
|
||||
const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`;
|
||||
const noPullRequest = pipeline.replace(' - event: [pull_request, manual]', ' - event: manual');
|
||||
const filteredPullRequest = pipeline.replace(
|
||||
' - event: [pull_request, manual]',
|
||||
' - event: [pull_request, manual]\n path: [scripts/**]',
|
||||
);
|
||||
|
||||
assert.throws(() => assertUnprivilegedGateStep(privilegedInstall), /privilege/i);
|
||||
assert.throws(() => assertUnprivilegedGateStep(duplicate), /exactly one gate-verify/);
|
||||
assert.throws(() => assertUnprivilegedGateStep(noPullRequest), /closed PR\/main construction/);
|
||||
assert.throws(
|
||||
() => assertUnprivilegedGateStep(filteredPullRequest),
|
||||
/closed PR\/main construction/,
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { verifyRegistry } from '../gate-verify.mjs';
|
||||
|
||||
let root;
|
||||
let manifest = 'gates/gates.manifest.json';
|
||||
let structureOnly = false;
|
||||
for (let index = 0; index < process.argv.slice(2).length; index += 1) {
|
||||
const args = process.argv.slice(2);
|
||||
const value = args[index];
|
||||
if (value === '--root') root = path.resolve(args[++index]);
|
||||
else if (value === '--manifest') manifest = args[++index];
|
||||
else if (value === '--structure-only') structureOnly = true;
|
||||
else throw new Error(`unknown fixture-runner option: ${value}`);
|
||||
}
|
||||
if (!root) throw new Error('fixture runner requires --root');
|
||||
const { failures, observations } = await verifyRegistry({
|
||||
root,
|
||||
manifest,
|
||||
structureOnly,
|
||||
fixtureProfile: true,
|
||||
});
|
||||
for (const observation of observations) process.stdout.write(`${observation}\n`);
|
||||
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
||||
if (failures.length > 0) process.exitCode = 1;
|
||||
+339
-163
@@ -1,184 +1,360 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
|
||||
# Greenfield installer acceptance fixture.
|
||||
#
|
||||
# Runs a clean-container install test to verify the full first-run flow:
|
||||
# tools/install.sh -> mosaic wizard (non-interactive)
|
||||
# -> mosaic gateway install
|
||||
# -> mosaic gateway verify
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Docker (skips gracefully if not available)
|
||||
# - Run from the repository root
|
||||
#
|
||||
# How it works:
|
||||
# 1. Mounts the repository into a node:22-alpine container.
|
||||
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
|
||||
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
|
||||
# framework and CLI from the Gitea registry.
|
||||
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
|
||||
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
|
||||
# 6. Runs `mosaic gateway verify` and checks its exit code.
|
||||
# NOTE: `mosaic gateway verify` is a new command added in the
|
||||
# feat/mosaic-first-run-ux branch. If the installed CLI version
|
||||
# pre-dates this branch (does not have `gateway verify`), the test
|
||||
# marks this step as EXPECTED-SKIP and reports the installed version.
|
||||
# 7. Reports PASS or FAIL with a summary.
|
||||
#
|
||||
# To run manually:
|
||||
# cd /path/to/mosaic-stack
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# The fixture itself is intentionally RED until the C2-C5 phase owners repair
|
||||
# their postconditions. C1's CI gate executes it and validates that the RED is
|
||||
# attributable (including the discriminating P3 PASS); it does not turn the
|
||||
# failed install into a false green.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="node:22-alpine"
|
||||
CONTAINER_NAME="mosaic-e2e-install-$$"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LANE="${MOSAIC_INSTALL_LANE:-next}"
|
||||
SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}"
|
||||
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
||||
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
||||
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
||||
IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}"
|
||||
|
||||
# ─── Colour helpers ───────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
|
||||
else
|
||||
R="" G="" Y="" BOLD="" RESET=""
|
||||
fi
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent]
|
||||
|
||||
info() { echo "${BOLD}[e2e]${RESET} $*"; }
|
||||
ok() { echo "${G}[PASS]${RESET} $*"; }
|
||||
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
|
||||
warn() { echo "${Y}[WARN]${RESET} $*"; }
|
||||
|
||||
# ─── Docker availability check ────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found — skipping e2e install test."
|
||||
warn "Install Docker and re-run this script to exercise the full install flow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! docker info &>/dev/null 2>&1; then
|
||||
warn "Docker daemon is not running or not accessible — skipping e2e install test."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Docker available — proceeding with e2e install test."
|
||||
info "Repo root: ${REPO_ROOT}"
|
||||
info "Container image: ${IMAGE}"
|
||||
|
||||
# ─── Inline script that runs INSIDE the container ────────────────────────────
|
||||
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
|
||||
trap 'rm -f "$INNER_SCRIPT"' EXIT
|
||||
|
||||
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
|
||||
#!/bin/sh
|
||||
# Bootstrap: /bin/sh until bash is installed, then re-exec.
|
||||
set -e
|
||||
|
||||
echo "=== [inner] Installing system prerequisites ==="
|
||||
apk add --no-cache bash curl jq git 2>/dev/null || \
|
||||
apt-get install -y -q bash curl jq git 2>/dev/null || true
|
||||
|
||||
# Re-exec under bash.
|
||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
# ── bash from here ────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== [inner] Node.js / npm versions ==="
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
echo "=== [inner] Setting up npm global prefix ==="
|
||||
export NPM_PREFIX="/root/.npm-global"
|
||||
mkdir -p "$NPM_PREFIX/bin"
|
||||
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
|
||||
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
|
||||
# Install both framework and CLI from the Gitea registry.
|
||||
MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||
MOSAIC_ASSUME_YES=1 \
|
||||
bash /repo/tools/install.sh --yes --no-auto-launch
|
||||
|
||||
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
|
||||
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
|
||||
|
||||
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
|
||||
mosaic wizard \
|
||||
--non-interactive \
|
||||
--name "test-agent" \
|
||||
--user-name "tester" \
|
||||
--pronouns "they/them" \
|
||||
--timezone "UTC" || {
|
||||
echo "[WARN] mosaic wizard exited non-zero — continuing"
|
||||
Runs the documented installer command from zero in Debian/glibc as a non-root
|
||||
uid with an isolated HOME. The fixture exits non-zero when any P0-P8
|
||||
postcondition fails. `next` is always selected with the --next installer flag.
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "=== [inner] Running mosaic gateway install ==="
|
||||
# Feed non-interactive answers:
|
||||
# "1" → storage tier: local
|
||||
# "" → port: accept default (14242)
|
||||
# "" → ANTHROPIC_API_KEY: skip
|
||||
# "" → CORS origin: accept default
|
||||
# Then admin bootstrap: name, email, password
|
||||
printf '1\n\n\n\nTest Admin\[email protected]\ntestpassword123\n' \
|
||||
| mosaic gateway install
|
||||
INSTALL_EXIT="$?"
|
||||
if [ "${INSTALL_EXIT}" -ne 0 ]; then
|
||||
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
|
||||
mosaic gateway status 2>/dev/null || true
|
||||
exit "${INSTALL_EXIT}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--lane) LANE="${2:-}"; shift 2 ;;
|
||||
--source) SOURCE="${2:-}"; shift 2 ;;
|
||||
--git) GIT_MODE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac
|
||||
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
||||
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
||||
|
||||
if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== [inner] Running mosaic gateway verify ==="
|
||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
||||
# If the installed version pre-dates this, skip gracefully.
|
||||
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
||||
# Treat as pass — the install flow itself worked.
|
||||
exit 0
|
||||
installer_b64=""
|
||||
framework_payload_count="NOT-MEASURED"
|
||||
repo_root_count="NOT-MEASURED"
|
||||
checkout_archive=""
|
||||
checkout_digest=""
|
||||
checkout_content_id=""
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
||||
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
||||
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$ROOT/skills" ]] \
|
||||
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")"
|
||||
repo_parent="$(dirname "$ROOT")"
|
||||
repo_name="$(basename "$ROOT")"
|
||||
tar -C "$repo_parent" \
|
||||
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||
--exclude='*/.env' --exclude='*/.env.*' \
|
||||
-czf "$checkout_archive" "$repo_name"
|
||||
checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')"
|
||||
checkout_content_id="${checkout_digest:0:40}"
|
||||
fi
|
||||
|
||||
mosaic gateway verify
|
||||
VERIFY_EXIT="$?"
|
||||
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
|
||||
exit "${VERIFY_EXIT}"
|
||||
INNER_SCRIPT_EOF
|
||||
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")"
|
||||
trap 'rm -f "$inner" "$checkout_archive"' EXIT
|
||||
cat > "$inner" <<'INNER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
chmod +x "$INNER_SCRIPT"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
packages=(bash ca-certificates curl jq passwd python3 util-linux)
|
||||
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
||||
apt-get install -y -qq "${packages[@]}" >/dev/null
|
||||
|
||||
# ─── Pull image ───────────────────────────────────────────────────────────────
|
||||
info "Pulling ${IMAGE}…"
|
||||
docker pull "${IMAGE}" --quiet
|
||||
if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then
|
||||
awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz
|
||||
actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')"
|
||||
if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then
|
||||
echo "[fixture] checkout archive transport digest mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Run container ────────────────────────────────────────────────────────────
|
||||
info "Starting container ${CONTAINER_NAME}…"
|
||||
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
||||
install -d -o mosaic -g mosaic /home/mosaic/work
|
||||
|
||||
EXIT_CODE=0
|
||||
docker run --rm \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
--volume "${REPO_ROOT}:/repo:ro" \
|
||||
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
|
||||
--network host \
|
||||
"${IMAGE}" \
|
||||
/bin/sh /e2e-inner.sh \
|
||||
|| EXIT_CODE=$?
|
||||
case "$FIXTURE_SOURCE" in
|
||||
checkout)
|
||||
printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh
|
||||
;;
|
||||
remote)
|
||||
curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh
|
||||
;;
|
||||
esac
|
||||
chmod 0755 /tmp/install.sh
|
||||
sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /'
|
||||
|
||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
|
||||
cat > /tmp/run-as-target.sh <<'TARGET'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
lane="$FIXTURE_LANE"
|
||||
home="$HOME"
|
||||
prefix="$home/.npm-global"
|
||||
mosaic_home="$home/.config/mosaic"
|
||||
install_log="$home/install.log"
|
||||
failures=0
|
||||
|
||||
phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; }
|
||||
phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); }
|
||||
|
||||
lane_args=()
|
||||
resolved_spec='@mosaicstack/mosaic'
|
||||
if [[ "$lane" == "next" ]]; then
|
||||
lane_args+=(--next)
|
||||
resolved_spec='@mosaicstack/mosaic@next'
|
||||
fi
|
||||
|
||||
resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)"
|
||||
printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}"
|
||||
|
||||
set +e
|
||||
MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \
|
||||
bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1
|
||||
install_status=$?
|
||||
set -e
|
||||
cat "$install_log"
|
||||
printf '[fixture] installer_exit=%d done_claims=%s\n' \
|
||||
"$install_status" "$(grep -cF 'Done.' "$install_log" || true)"
|
||||
|
||||
# P0 Resolve context
|
||||
shell="$(getent passwd "$(id -u)" | cut -d: -f7)"
|
||||
if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \
|
||||
&& ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \
|
||||
&& [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then
|
||||
phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)"
|
||||
else
|
||||
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
|
||||
echo ""
|
||||
echo " Troubleshooting:"
|
||||
echo " - Review the output above for the failing step."
|
||||
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
|
||||
echo " - Run mosaic gateway logs inside a manual container for daemon output."
|
||||
phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})"
|
||||
fi
|
||||
|
||||
# P1 Preflight
|
||||
missing_tools=()
|
||||
for tool in bash curl git node npm python3 tar; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
||||
done
|
||||
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
||||
phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved"
|
||||
else
|
||||
phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)"
|
||||
fi
|
||||
|
||||
# P2 Acquire artifacts
|
||||
if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then
|
||||
phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript"
|
||||
else
|
||||
phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version"
|
||||
fi
|
||||
|
||||
# P3 Install CLI — the discriminating row. Use the known absolute path only.
|
||||
cli="$prefix/bin/mosaic"
|
||||
cli_version=""
|
||||
if [[ -x "$cli" ]]; then
|
||||
cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)"
|
||||
fi
|
||||
if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then
|
||||
phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version"
|
||||
else
|
||||
phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}"
|
||||
fi
|
||||
|
||||
# P4 Framework + skills. C1 does not choose among the four disagreeing
|
||||
# candidate populations. It requires the installer to publish a lane/versioned
|
||||
# shipped-set declaration that a checkout-free install can resolve; C5 owns its
|
||||
# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count.
|
||||
declared_set="$mosaic_home/.install-shipped-skills.json"
|
||||
sync_store_count=0
|
||||
runtime_link_count=0
|
||||
[[ -d "$mosaic_home/skills" ]] \
|
||||
&& sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$home/.pi/agent/skills" ]] \
|
||||
&& runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')"
|
||||
printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \
|
||||
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
||||
if [[ ! -s "$declared_set" ]]; then
|
||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
||||
elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \
|
||||
MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
|
||||
if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION ||
|
||||
!Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
|
||||
for (const name of data.skills) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
|
||||
const skill = path.join(root, name, 'SKILL.md');
|
||||
let real;
|
||||
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
|
||||
const text = fs.readFileSync(real, 'utf8');
|
||||
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
|
||||
if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1);
|
||||
}
|
||||
NODE
|
||||
then
|
||||
declared_count="$(node -p "require('$declared_set').skills.length")"
|
||||
if [[ -s "$mosaic_home/.install-manifest.json" ]] \
|
||||
&& [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then
|
||||
phase_fail P4 "declared skills are present but the required framework/skills action reported failure"
|
||||
else
|
||||
phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable"
|
||||
fi
|
||||
else
|
||||
phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
||||
fi
|
||||
|
||||
# P5 Identity
|
||||
identity_ok=true
|
||||
identity_reason=()
|
||||
for f in SOUL.md USER.md; do
|
||||
path="$mosaic_home/$f"
|
||||
if [[ ! -s "$path" ]]; then
|
||||
identity_ok=false; identity_reason+=("$f missing-or-empty"); continue
|
||||
fi
|
||||
owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")"
|
||||
if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then
|
||||
identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode")
|
||||
fi
|
||||
done
|
||||
if [[ "$identity_ok" == true ]]; then
|
||||
phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes"
|
||||
else
|
||||
phase_fail P5 "${identity_reason[*]}"
|
||||
fi
|
||||
|
||||
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
broker_present=false
|
||||
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
||||
dead_hooks=0
|
||||
if [[ -f "$home/.claude/settings.json" ]]; then
|
||||
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
||||
fi
|
||||
p6_action_failed=false
|
||||
if [[ -s "$manifest" ]]; then
|
||||
p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)"
|
||||
fi
|
||||
if [[ "$p6_action_failed" == true ]]; then
|
||||
phase_fail P6 "runtime linking/activation action reported a required failure"
|
||||
elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
||||
elif [[ "$broker_present" == true ]]; then
|
||||
phase_pass P6 "activation broker present; hook state is evaluable"
|
||||
else
|
||||
phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)"
|
||||
fi
|
||||
|
||||
# P7 Services — none requested by --no-auto-launch.
|
||||
phase_pass P7 "no services requested by this fixture"
|
||||
|
||||
# P8 Shell discoverability — actual target shell, fresh login and non-login.
|
||||
base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin)
|
||||
login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)"
|
||||
nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)"
|
||||
if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then
|
||||
phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path"
|
||||
else
|
||||
phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli"
|
||||
fi
|
||||
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
p0_p8_failures="$failures"
|
||||
if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then
|
||||
phase_pass P9 "P0-P8 reasserted; manifest present"
|
||||
else
|
||||
phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success"
|
||||
fi
|
||||
|
||||
printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures"
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
TARGET
|
||||
chmod 0755 /tmp/run-as-target.sh
|
||||
chown mosaic:mosaic /tmp/run-as-target.sh
|
||||
|
||||
exec runuser -u mosaic -- env -i \
|
||||
HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \
|
||||
PATH=/usr/local/bin:/usr/bin:/bin \
|
||||
FIXTURE_LANE="$FIXTURE_LANE" \
|
||||
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \
|
||||
/bin/bash /tmp/run-as-target.sh
|
||||
INNER
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
{
|
||||
printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n'
|
||||
base64 "$checkout_archive"
|
||||
} >> "$inner"
|
||||
fi
|
||||
chmod 0755 "$inner"
|
||||
|
||||
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
||||
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
||||
|
||||
if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then
|
||||
# Woodpecker already supplies the clean Debian container. The target install
|
||||
# still runs through runuser + env -i, so CI variables/credentials do not
|
||||
# enter the target user's process.
|
||||
FIXTURE_LANE="$LANE" \
|
||||
FIXTURE_SOURCE="$SOURCE" \
|
||||
FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
/bin/bash "$inner"
|
||||
else
|
||||
# Copy the self-contained script+archive into a stopped container instead of
|
||||
# bind-mounting the checkout or passing host paths. The target runtime still
|
||||
# inherits no host HOME/cache/credentials, and the multi-megabyte checkout
|
||||
# payload avoids argv/environment size limits.
|
||||
fixture_cid="$(docker create \
|
||||
--network bridge \
|
||||
--env FIXTURE_LANE="$LANE" \
|
||||
--env FIXTURE_SOURCE="$SOURCE" \
|
||||
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
--env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
--env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
"$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)"
|
||||
docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh"
|
||||
set +e
|
||||
docker start -a "$fixture_cid"
|
||||
fixture_status=$?
|
||||
set -e
|
||||
docker rm "$fixture_cid" >/dev/null
|
||||
exit "$fixture_status"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Pinned C1 expected-RED contract. Updating a verdict/reason requires review by the owning remediation lane.
|
||||
# case kind key/value
|
||||
next-git-present exit 1
|
||||
next-git-present phase P0=PASS
|
||||
next-git-present phase P1=PASS
|
||||
next-git-present phase P2=PASS
|
||||
next-git-present phase P3=PASS
|
||||
next-git-present phase P4=FAIL
|
||||
next-git-present phase P5=FAIL
|
||||
next-git-present phase P6=FAIL
|
||||
next-git-present phase P7=PASS
|
||||
next-git-present phase P8=FAIL
|
||||
next-git-present phase P9=FAIL
|
||||
next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.
|
||||
next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||
next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||
next-git-present require ^\[P6\] FAIL:
|
||||
next-git-present forbid Done\.
|
||||
main-git-present exit 1
|
||||
main-git-present phase P0=PASS
|
||||
main-git-present phase P1=PASS
|
||||
main-git-present phase P2=PASS
|
||||
main-git-present phase P3=PASS
|
||||
main-git-present phase P4=FAIL
|
||||
main-git-present phase P5=FAIL
|
||||
main-git-present phase P6=FAIL
|
||||
main-git-present phase P7=PASS
|
||||
main-git-present phase P8=FAIL
|
||||
main-git-present phase P9=FAIL
|
||||
main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$
|
||||
main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||
main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||
main-git-present require ^\[P6\] FAIL:
|
||||
main-git-present forbid Done\.
|
||||
next-git-absent exit 1
|
||||
next-git-absent phase P0=PASS
|
||||
next-git-absent phase P1=FAIL
|
||||
next-git-absent phase P2=FAIL
|
||||
next-git-absent phase P3=FAIL
|
||||
next-git-absent phase P4=FAIL
|
||||
next-git-absent phase P5=FAIL
|
||||
next-git-absent phase P6=PASS
|
||||
next-git-absent phase P7=PASS
|
||||
next-git-absent phase P8=FAIL
|
||||
next-git-absent phase P9=FAIL
|
||||
next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git;
|
||||
next-git-absent require ^\[P3\] FAIL: .*executable=no
|
||||
next-git-absent forbid Done\.
|
||||
|
Executable
+359
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$HOME_DIR/prefix"
|
||||
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
|
||||
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
echo "$*" >> "$LOG"
|
||||
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
echo "10.6.2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install_cli() {
|
||||
local version="$1"
|
||||
echo "$version" > "$STATE/mosaic"
|
||||
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\\n' '$version'
|
||||
CLI
|
||||
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||
}
|
||||
|
||||
if [[ "$1" == "view" ]]; then
|
||||
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||
echo "forced registry metadata failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
install_cli "0.0.49-next.999"
|
||||
;;
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||
echo "forced gateway install failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||
;;
|
||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||
install_cli "0.0.0-source"
|
||||
;;
|
||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/gateway"
|
||||
;;
|
||||
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "ls" ]]; then
|
||||
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||
node -e '
|
||||
const cli = process.argv[1];
|
||||
const gateway = process.argv[2];
|
||||
const dependencies = {};
|
||||
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||
process.stdout.write(JSON.stringify({ dependencies }));
|
||||
' "$cli" "$gateway"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected npm command: $*" >&2
|
||||
exit 1
|
||||
FAKE_NPM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
headers=""; output=""; url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-D) headers="$2"; shift 2 ;;
|
||||
-o) output="$2"; shift 2 ;;
|
||||
--max-filesize) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||
;;
|
||||
*/archive/*.tar.gz)
|
||||
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||
printf 'not-a-tarball\n' > "$output"
|
||||
else
|
||||
archive_root="$(mktemp -d)"
|
||||
mkdir -p "$archive_root/stack"
|
||||
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||
/bin/tar czf "$output" -C "$archive_root" stack
|
||||
rm -rf "$archive_root"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""; list=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
-*t*|t*) list=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[[ "$list" == true ]] && exit 0
|
||||
if [[ -z "$dest" ]]; then
|
||||
echo "fake tar missing -C destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
|
||||
FAKE_TAR
|
||||
chmod +x "$FAKE_BIN/tar"
|
||||
|
||||
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
echo "pnpm $*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "pack" ]]; then
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pack-destination) out="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$out" ]]; then
|
||||
echo "fake pnpm pack missing destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
case "$PWD" in
|
||||
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||
echo "forced pnpm install failure" >&2
|
||||
exit 42
|
||||
fi
|
||||
|
||||
# Other install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
prefix_fingerprint() {
|
||||
if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi
|
||||
(
|
||||
cd "$PREFIX"
|
||||
find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort
|
||||
find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum
|
||||
) | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
reset_state
|
||||
echo "[test] --next fast path pins resolved package versions"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||
echo "expected exact-version installs, found mutable @next install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||
|
||||
reset_state
|
||||
echo "[test] fast path failure falls back to source build"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] --dev source install does not require registry version resolution"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||
)"
|
||||
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
|
||||
reset_state
|
||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
if grep -qF '@next version' "$LOG"; then
|
||||
echo "explicit ref should not query @next dist-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first acceptance checks for #1050. This file is committed before the
|
||||
# installer implementation. Do not weaken these properties to make it green.
|
||||
|
||||
# pass_case always returns zero and fail_case records the aggregate failure;
|
||||
# the compact A&&pass||fail assertions are intentional.
|
||||
# shellcheck disable=SC2015
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
failures=0
|
||||
COMPAT_BIN="$TMP/compat-bin"
|
||||
mkdir -p "$COMPAT_BIN"
|
||||
cat > "$COMPAT_BIN/realpath" <<'REALPATH'
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
mode = args.pop(0) if args and args[0] in ("-e", "-m") else "-m"
|
||||
if args and args[0] == "--":
|
||||
args.pop(0)
|
||||
if len(args) != 1 or (mode == "-e" and not os.path.exists(args[0])):
|
||||
raise SystemExit(1)
|
||||
print(os.path.realpath(args[0]))
|
||||
REALPATH
|
||||
chmod 0755 "$COMPAT_BIN/realpath"
|
||||
|
||||
fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); }
|
||||
pass_case() { printf '[test] PASS: %s\n' "$*"; }
|
||||
|
||||
fingerprint() {
|
||||
local dir="$1"
|
||||
if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi
|
||||
python3 - "$dir" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
root = os.path.abspath(sys.argv[1])
|
||||
rows = []
|
||||
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||
for name in dirs + files:
|
||||
path = os.path.join(current, name)
|
||||
rel = os.path.relpath(path, root)
|
||||
meta = os.lstat(path)
|
||||
target = os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ""
|
||||
digest = ""
|
||||
if stat.S_ISREG(meta.st_mode):
|
||||
with open(path, "rb") as handle:
|
||||
digest = hashlib.sha256(handle.read()).hexdigest()
|
||||
rows.append((rel, stat.S_IFMT(meta.st_mode), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid, target, digest))
|
||||
payload = "\n".join("|".join(map(str, row)) for row in sorted(rows)).encode()
|
||||
print(hashlib.sha256(payload).hexdigest())
|
||||
PY
|
||||
}
|
||||
|
||||
make_fake_npm() {
|
||||
local bin="$1"
|
||||
mkdir -p "$bin"
|
||||
cat > "$bin/npm" <<'FAKE'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi
|
||||
case "${1:-} ${2:-} ${3:-}" in
|
||||
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
|
||||
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
|
||||
'view @mosaicstack/mosaic version') echo '0.0.49' ;;
|
||||
'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||
ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||
*) echo "unexpected fake npm command: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod 0755 "$bin/npm"
|
||||
}
|
||||
|
||||
printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n'
|
||||
check_home="$TMP/check-home"
|
||||
check_bin="$TMP/check-bin"
|
||||
mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin"
|
||||
printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md"
|
||||
printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md"
|
||||
cat > "$check_home/.npm-global/bin/mosaic" <<'CLI'
|
||||
#!/usr/bin/env bash
|
||||
printf '0.0.50-next.999\n'
|
||||
CLI
|
||||
chmod 0755 "$check_home/.npm-global/bin/mosaic"
|
||||
make_fake_npm "$check_bin"
|
||||
before="$(fingerprint "$check_home")"
|
||||
set +e
|
||||
HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1
|
||||
check_status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$check_home")"
|
||||
|
||||
[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \
|
||||
|| fail_case "--check mutated HOME (before=$before after=$after)"
|
||||
[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \
|
||||
|| fail_case '--check returned zero on the deliberately broken host'
|
||||
|
||||
phase_rows=0
|
||||
for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do
|
||||
count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)"
|
||||
[[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count"
|
||||
phase_rows=$((phase_rows + count))
|
||||
done
|
||||
[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \
|
||||
|| fail_case "--check emitted $phase_rows canonical rows instead of 9"
|
||||
grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \
|
||||
&& pass_case 'P3 preserves the absolute-path exact-version discriminator' \
|
||||
|| fail_case 'P3 did not PASS with the exact resolved next-lane version'
|
||||
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \
|
||||
&& pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \
|
||||
|| fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED'
|
||||
for phase in P5 P8; do
|
||||
grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \
|
||||
&& pass_case "$phase remains an attributable expected RED" \
|
||||
|| fail_case "$phase did not report its own expected failure"
|
||||
done
|
||||
|
||||
printf '[test] case: --check discriminates a constructed good host without mutation\n'
|
||||
good_home="$TMP/good-home"
|
||||
good_bin="$TMP/good-bin"
|
||||
good_prefix="$good_home/.npm-global"
|
||||
good_mosaic="$good_home/.config/mosaic"
|
||||
mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill"
|
||||
make_fake_npm "$good_bin"
|
||||
cp "$COMPAT_BIN/realpath" "$good_bin/realpath"
|
||||
cat > "$good_bin/id" <<'ID'
|
||||
#!/bin/bash
|
||||
case "${1:-}" in
|
||||
-u) echo 1001 ;;
|
||||
-g) echo 1001 ;;
|
||||
-un) echo fixture-user ;;
|
||||
*) exec /bin/id "$@" ;;
|
||||
esac
|
||||
ID
|
||||
cat > "$good_bin/stat" <<'STAT'
|
||||
#!/bin/bash
|
||||
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then echo 1001; exit 0; fi
|
||||
exec /bin/stat "$@"
|
||||
STAT
|
||||
cat > "$good_bin/curl" <<'CURL'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
CURL
|
||||
cat > "$good_bin/ldd" <<'LDD'
|
||||
#!/bin/bash
|
||||
echo 'ldd (GNU libc) 2.36'
|
||||
LDD
|
||||
chmod 0755 "$good_bin/id" "$good_bin/stat" "$good_bin/curl" "$good_bin/ldd"
|
||||
cat > "$good_prefix/bin/mosaic" <<'CLI'
|
||||
#!/usr/bin/env bash
|
||||
printf '0.0.50-next.999\n'
|
||||
CLI
|
||||
chmod 0755 "$good_prefix/bin/mosaic"
|
||||
cat > "$good_bin/getent" <<GETENT
|
||||
#!/bin/bash
|
||||
printf '%s:x:%s:%s::%s:%s\\n' '$(id -un)' '$(id -u)' '$(id -g)' '$good_home' '$good_bin/bash'
|
||||
GETENT
|
||||
cat > "$good_bin/bash" <<SHELL
|
||||
#!/bin/bash
|
||||
if [[ "\${*: -1}" == 'command -v mosaic' ]]; then
|
||||
printf '%s\\n' '$good_prefix/bin/mosaic'
|
||||
exit 0
|
||||
fi
|
||||
exec /bin/bash "\$@"
|
||||
SHELL
|
||||
chmod 0755 "$good_bin/getent" "$good_bin/bash"
|
||||
printf '# Soul\n\nConfigured.\n' > "$good_mosaic/SOUL.md"
|
||||
printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md"
|
||||
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||
cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL'
|
||||
---
|
||||
name: declared-skill
|
||||
description: Constructed loadable acceptance skill.
|
||||
---
|
||||
|
||||
# Declared skill
|
||||
SKILL
|
||||
printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json"
|
||||
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||
before="$(fingerprint "$good_home")"
|
||||
set +e
|
||||
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$good_home")"
|
||||
[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status"
|
||||
[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME'
|
||||
good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)"
|
||||
[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \
|
||||
|| { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; }
|
||||
|
||||
printf '[test] case: persisted required-action failures remain blocking\n'
|
||||
for blocked_phase in P4 P6; do
|
||||
node -e '
|
||||
const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2];
|
||||
const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"};
|
||||
m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n");
|
||||
' "$good_mosaic/.install-manifest.json" "$blocked_phase"
|
||||
set +e
|
||||
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero"
|
||||
grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \
|
||||
&& pass_case "$blocked_phase action failure remained blocking in a later --check" \
|
||||
|| fail_case "$blocked_phase persisted action failure was not attributed"
|
||||
done
|
||||
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||
|
||||
printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n'
|
||||
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||
home="$TMP/fault-$phase/home"
|
||||
state="$TMP/fault-$phase/state"
|
||||
mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state"
|
||||
printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt"
|
||||
printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc"
|
||||
printf 'old-cli\n' > "$home/.npm-global/bin/mosaic"
|
||||
printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json"
|
||||
before="$(fingerprint "$home")"
|
||||
set +e
|
||||
HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||
>"$TMP/fault-$phase.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$home")"
|
||||
[[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero"
|
||||
grep -q "phase=$phase" "$TMP/fault-$phase.log" \
|
||||
|| fail_case "$phase fault transcript did not name the injected phase"
|
||||
[[ "$before" == "$after" ]] \
|
||||
&& pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \
|
||||
|| fail_case "$phase rollback mismatch (before=$before after=$after)"
|
||||
if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then
|
||||
fail_case "$phase left a journal in-progress"
|
||||
else
|
||||
pass_case "$phase left no journal falsely in-progress"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n'
|
||||
unsafe_home="$TMP/unsafe-home"
|
||||
mkdir -p "$unsafe_home"
|
||||
for case_name in root-target home-target overlap-target; do
|
||||
case "$case_name" in
|
||||
root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||
home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||
overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;;
|
||||
esac
|
||||
before="$(fingerprint "$unsafe_home")"
|
||||
set +e
|
||||
HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$unsafe_home")"
|
||||
[[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero"
|
||||
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \
|
||||
&& pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure"
|
||||
[[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME"
|
||||
done
|
||||
|
||||
symlink_home="$TMP/symlink-home"
|
||||
symlink_outside="$TMP/symlink-outside"
|
||||
mkdir -p "$symlink_home" "$symlink_outside"
|
||||
ln -s "$symlink_outside" "$symlink_home/.config"
|
||||
set +e
|
||||
HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero'
|
||||
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \
|
||||
&& pass_case 'symlinked rollback parent was rejected by P0' \
|
||||
|| fail_case 'symlinked rollback parent lacked an attributable P0 failure'
|
||||
[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated'
|
||||
|
||||
printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n'
|
||||
stale_home="$TMP/stale/home"
|
||||
stale_state="$TMP/stale/state"
|
||||
mkdir -p "$stale_home/.config/mosaic" "$stale_state"
|
||||
printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json"
|
||||
set +e
|
||||
HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \
|
||||
PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status"
|
||||
if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then
|
||||
pass_case 'stale projection was preserved and superseded after the free OS lock was acquired'
|
||||
else
|
||||
fail_case 'stale projection was not preserved for recovery evidence'
|
||||
fi
|
||||
[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \
|
||||
|| fail_case 'stale retry did not reach an honest rolled-back terminal state'
|
||||
|
||||
printf '[test] case: journal initialization failure is fatal before mutation\n'
|
||||
journal_home="$TMP/journal-failure/home"
|
||||
mkdir -p "$journal_home/.config/mosaic"
|
||||
printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt"
|
||||
before="$(fingerprint "$journal_home")"
|
||||
set +e
|
||||
HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||
MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||
>"$TMP/journal-failure.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$journal_home")"
|
||||
[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \
|
||||
|| fail_case 'unwritable journal directory returned zero'
|
||||
grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \
|
||||
&& pass_case 'journal initialization failure was named' \
|
||||
|| fail_case 'journal initialization failure lacked a named diagnostic'
|
||||
[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \
|
||||
|| fail_case "journal failure mutated target HOME (before=$before after=$after)"
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
|
||||
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] installer state-machine acceptance passed\n'
|
||||
+1148
-61
File diff suppressed because it is too large
Load Diff
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that the detector found exactly the pinned C1 phase verdicts. The
|
||||
# fixture is expected to exit non-zero; this verifier is the green CI contract.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST="${MOSAIC_EXPECTED_RED_MANIFEST:-$ROOT/tools/fixtures/greenfield-expected-red.tsv}"
|
||||
CASE="${1:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
LOG="${2:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
FIXTURE_EXIT="${3:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
|
||||
[[ -r "$MANIFEST" ]] || { echo "expected-RED manifest is unreadable: $MANIFEST" >&2; exit 2; }
|
||||
[[ -r "$LOG" ]] || { echo "fixture log is unreadable: $LOG" >&2; exit 2; }
|
||||
[[ "$FIXTURE_EXIT" =~ ^[0-9]+$ ]] || { echo "fixture exit is not numeric: $FIXTURE_EXIT" >&2; exit 2; }
|
||||
|
||||
checks=0
|
||||
failures=0
|
||||
while IFS=$'\t' read -r case_name kind expectation; do
|
||||
[[ -n "$case_name" && "${case_name:0:1}" != "#" ]] || continue
|
||||
[[ "$case_name" == "$CASE" ]] || continue
|
||||
checks=$((checks + 1))
|
||||
case "$kind" in
|
||||
exit)
|
||||
if [[ "$FIXTURE_EXIT" != "$expectation" ]]; then
|
||||
echo "expected-RED mismatch: case=$CASE fixture_exit=$FIXTURE_EXIT expected=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
phase)
|
||||
phase="${expectation%%=*}"
|
||||
expected_verdict="${expectation#*=}"
|
||||
last_row="$(grep -E "^\[$phase\] (PASS|FAIL):" "$LOG" | tail -n 1 || true)"
|
||||
actual_verdict="$(printf '%s\n' "$last_row" | sed -n "s/^\[$phase\] \(PASS\|FAIL\):.*/\1/p")"
|
||||
if [[ "$actual_verdict" != "$expected_verdict" ]]; then
|
||||
echo "expected-RED mismatch: case=$CASE phase=$phase got=${actual_verdict:-missing} expected=$expected_verdict" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
require)
|
||||
if ! grep -Eq -- "$expectation" "$LOG"; then
|
||||
echo "expected-RED missing required evidence: case=$CASE regex=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
forbid)
|
||||
if grep -Eq -- "$expectation" "$LOG"; then
|
||||
echo "expected-RED found forbidden evidence: case=$CASE regex=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done < "$MANIFEST"
|
||||
|
||||
[[ "$checks" -gt 0 ]] || { echo "expected-RED manifest has no checks for case=$CASE" >&2; exit 2; }
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "expected-RED verification failed: case=$CASE failures=$failures checks=$checks" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'expected-RED verification passed: case=%s checks=%d\n' "$CASE" "$checks"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-expected-red-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
cat > "$TMP/match.log" <<'LOG'
|
||||
[fixture] resolved lane=next package=@mosaicstack/mosaic@next version=0.0.50-next.999
|
||||
[fixture] installer_exit=1 done_claims=0
|
||||
[P0] PASS: supported context
|
||||
[P1] PASS: preflight complete
|
||||
[P2] PASS: pinned artifact
|
||||
[P3] PASS: absolute_path=/home/test/.npm-global/bin/mosaic version=0.0.50-next.999 equals resolved lane version
|
||||
[P4] FAIL: NOT-MEASURED / UNDECLARED: declaration absent
|
||||
[P5] FAIL: identity absent
|
||||
[P6] FAIL: activation unavailable
|
||||
[P7] PASS: no services requested
|
||||
[P8] FAIL: shell path absent
|
||||
[P9] FAIL: aggregate refusal
|
||||
LOG
|
||||
|
||||
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null
|
||||
printf '[test] PASS: matching detector findings make the CI verifier green\n'
|
||||
|
||||
sed 's/^\[P4\] FAIL:/[P4] PASS:/' "$TMP/match.log" > "$TMP/drift.log"
|
||||
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/drift.log" 1 >/dev/null 2>&1; then
|
||||
echo '[test] FAIL: changed P4 verdict did not invalidate the pinned manifest' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] PASS: changed phase verdict requires a deliberate manifest update\n'
|
||||
|
||||
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 0 >/dev/null 2>&1; then
|
||||
echo '[test] FAIL: unexpected fixture exit did not invalidate the pinned manifest' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] PASS: unexpected fixture exit remains blocking\n'
|
||||
Reference in New Issue
Block a user