Compare commits

...
Author SHA1 Message Date
be-coder-08 113bac9e7c fix(pr-merge): harden provider message path
ci/woodpecker/pr/ci Pipeline was successful
2026-08-06 00:10:48 -05:00
be-coder-08 1ceb11058f feat(pr-merge): preserve linked co-authors on squash
ci/woodpecker/pr/ci Pipeline was successful
2026-08-05 18:57:28 -05:00
be-coder-08 abe9dbb5c1 test(pr-merge): register message field contract 2026-08-05 17:31:54 -05:00
be-coder-08andMos 85d2108e4e fix(ci): remove upgrade rollback signal race (#1060)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 22:14:15 +00:00
be-coder-08andMos 16f91157a1 test(ci): make queue guard harness deterministic (#1062)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 21:49:44 +00:00
coder-mos1andmos-dt-0 5916aeefd6 chore(release): @mosaicstack/mosaic 0.0.49 — ship RM-03 guard fix to release channel (#1036)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Co-authored-by: coder-mos1 <[email protected]>
2026-08-02 20:09:11 +00:00
coder-mos1andmos-dt-0 58b971aba3 fix(rm-03): make CI queue guard fail on asserted non-readiness (#1032)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 18:55:26 +00:00
32 changed files with 2481 additions and 309 deletions
@@ -0,0 +1,71 @@
# #1019 — Zero-timeout queue-guard harness race
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
- **Branch:** `fix/1019-ci-queue-timeout-harness`
- **Owner:** `be-coder-08`
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
## Objective
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
## Scope
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
## Acceptance criteria
1. RED deterministically reproduces deadline pre-emption before the provider call.
2. Every case that intends status classification positively proves provider observation.
3. Pending observes `pending` before deterministic virtual-time expiration.
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
5. The exact CI-base image passes the final harness repeatedly with zero failures.
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
## Plan
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
## Budget
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
## Evidence
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log`**100 pass / 0 fail / 100 total**.
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
## Review remediation — semantic timeout vs. liveness bound
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
Remediation:
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
Post-review evidence:
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
## 60% context hold
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
@@ -0,0 +1,99 @@
# PR merge squash message field
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
- **Owner:** `be-coder-08`
- **Branch:** `fix/pr-merge-message-field`
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
## Objective
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
## Binding requirements
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
## Derived interface decisions
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
## Canonical delivery plan
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
## Evidence
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
## Remediation and current review state
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
4. Codex review rounds 35 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
## Disposable provider fixture acceptance
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
## Fixture preflight
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
## Fixture result
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
- Trailer-shaped lines, verbatim and in order:
1. `Co-authored-by: be-coder-07 <[email protected]>`
2. `Co-authored-by: be-coder-08 <[email protected]>`
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
## Current hold point
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
## Security review 96 remediation
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
Security remediation:
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
+120
View File
@@ -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.
@@ -925,14 +925,16 @@ Woodpecker note:
Before pushing a branch or merging a PR, guard against overlapping project pipelines: Before pushing a branch or merging a PR, guard against overlapping project pipelines:
```bash ```bash
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
``` ```
Behavior: Behavior:
- If pipeline state is running/queued/pending, wait until queue clears. - If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop. - Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
## Gitea as Unified Platform ## Gitea as Unified Platform
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
- PR target for delivery is `main`. - PR target for delivery is `main`.
- Direct pushes to `main` are prohibited. - Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only. - Merge to `main` MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent). - Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
## Review Checklist ## Review Checklist
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes. 9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers. 10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`. 11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow). 12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable). 13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes). 14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds > the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
> without asking. > without asking.
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main` 1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash` 2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>` 3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists) 4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop. 5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
@@ -425,11 +425,11 @@ git push
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable. and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks): 13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
- Before merging, run queue guard: - Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main` `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed): - Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B main` `~/.config/mosaic/tools/git/pr-create.sh ... -B main`
- Merge via wrapper: - Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status: - Wait for terminal CI status:
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}` `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- Close linked issue after merge + green CI: - Close linked issue after merge + green CI:
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured **MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched. (ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below) Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
## Git Scripts ## Git Scripts
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}` - `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main` - `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main` - Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` - Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}` - `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}` - `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
# Milestones # Milestones
~/.config/mosaic/tools/git/milestone-create.sh ~/.config/mosaic/tools/git/milestone-create.sh
# CI queue guard (required before push/merge) # CI queue guard (required before push/merge; defaults to the checked-out branch)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
``` ```
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex) ### Code Review (Codex)
```bash ```bash
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -88,7 +88,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -97,7 +97,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -101,7 +101,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -87,7 +87,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -84,7 +84,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -85,7 +85,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -7,7 +7,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh" source "$SCRIPT_DIR/detect-platform.sh"
BRANCH="main" BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
TIMEOUT_SEC=900 TIMEOUT_SEC=900
INTERVAL_SEC=15 INTERVAL_SEC=15
PURPOSE="merge" PURPOSE="merge"
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options: Options:
-B, --branch BRANCH Branch head to inspect (default: main) -B, --branch BRANCH Branch head to inspect (default: current branch)
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
-t, --timeout SECONDS Max wait time in seconds (default: 900) -t, --timeout SECONDS Max wait time in seconds (default: 900)
-i, --interval SECONDS Poll interval in seconds (default: 15) -i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge) --purpose VALUE Log context: push|merge (default: merge)
@@ -27,63 +31,65 @@ Options:
Examples: Examples:
$(basename "$0") $(basename "$0")
$(basename "$0") --purpose push -B main -t 600 -i 10 $(basename "$0") --purpose push -t 600 -i 10
EOF EOF
} }
# get_remote_host and get_gitea_token are provided by detect-platform.sh # get_remote_host and get_gitea_token are provided by detect-platform.sh
get_state_from_status_json() { get_state_from_status_json() {
python3 - <<'PY' # Python source comes from -c so the provider payload remains on stdin.
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
python3 -c '
import json import json
import sys import sys
try: try:
payload = json.load(sys.stdin) payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError("status payload is not an object")
except Exception: except Exception:
print("unknown") print("malformed")
raise SystemExit(0) raise SystemExit(0)
statuses = payload.get("statuses") or [] raw_statuses = payload.get("statuses", [])
state = (payload.get("state") or "").lower() raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed")
raise SystemExit(0)
statuses = raw_statuses
state = raw_state.lower()
pending_values = {"pending", "queued", "running", "waiting"} pending_values = {"pending", "queued", "running", "waiting"}
failure_values = {"failure", "error", "failed"} failure_values = {"failure", "error", "failed"}
success_values = {"success"} success_values = {"success"}
if state in pending_values:
print("pending")
raise SystemExit(0)
if state in failure_values:
print("terminal-failure")
raise SystemExit(0)
if state in success_values:
print("terminal-success")
raise SystemExit(0)
values = [] values = []
for item in statuses: for item in statuses:
if not isinstance(item, dict): if not isinstance(item, dict):
continue print("malformed")
value = (item.get("status") or item.get("state") or "").lower() raise SystemExit(0)
if value: raw_value = item.get("status") or item.get("state")
values.append(value) if not isinstance(raw_value, str) or not raw_value:
print("malformed")
raise SystemExit(0)
values.append(raw_value.lower())
if not values and not state: if any(value in pending_values for value in values) or state in pending_values:
print("no-status")
elif any(v in pending_values for v in values):
print("pending") print("pending")
elif any(v in failure_values for v in values): elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure") print("terminal-failure")
elif values and all(v in success_values for v in values): elif values and all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success") print("terminal-success")
elif not values:
print("no-status")
else: else:
print("unknown") print("unknown")
PY '
} }
print_pending_contexts() { print_pending_contexts() {
python3 - <<'PY' python3 -c '
import json import json
import sys import sys
@@ -104,17 +110,61 @@ for item in statuses:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
name = item.get("context") or item.get("name") or "unknown-context" name = item.get("context") or item.get("name") or "unknown-context"
value = (item.get("status") or item.get("state") or "unknown").lower() value = str(item.get("status") or item.get("state") or "unknown").lower()
target = item.get("target_url") or item.get("url") or "" target = item.get("target_url") or item.get("url") or ""
if value in pending_values: if value in pending_values:
found = True found = True
if target: suffix = f" ({target})" if target else ""
print(f"[ci-queue-wait] pending: {name}={value} ({target})") print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
else:
print(f"[ci-queue-wait] pending: {name}={value}")
if not found: if not found:
print("[ci-queue-wait] no pending contexts") print("[ci-queue-wait] no pending contexts")
'
}
record_cannot_assert() {
local reason="$1"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
return 70
fi
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, reason, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": "CANNOT_ASSERT",
"reason": reason,
"platform": platform,
"purpose": purpose,
"disposition": "hold" if purpose == "merge" else "degraded-pass",
"branch": branch,
"repo": repo,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY PY
then
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
return 70
fi
if [[ "$PURPOSE" == "merge" ]]; then
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
return 75
fi
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
return 0
} }
github_get_branch_head_sha() { github_get_branch_head_sha() {
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
local owner="$1" local owner="$1"
local repo="$2" local repo="$2"
local sha="$3" local sha="$3"
gh api "repos/${owner}/${repo}/commits/${sha}/status" local work_root status_file checks_file
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
mkdir -p "$work_root" || return 1
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
rm -f "$status_file"
return 1
}
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
rm -f "$status_file" "$checks_file"
return 1
fi
python3 - "$status_file" "$checks_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
status_pages = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
check_pages = json.load(handle)
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
raise SystemExit(1)
# The statuses endpoint is newest-first and can contain retries for one context.
# Keep only the newest entry per context after flattening every page.
combined = []
seen_contexts = set()
for page in status_pages:
if not isinstance(page, list):
raise SystemExit(1)
for status in page:
if not isinstance(status, dict):
raise SystemExit(1)
context = status.get("context")
if not isinstance(context, str) or not context or context in seen_contexts:
continue
seen_contexts.add(context)
combined.append(status)
check_runs = []
reported_total = 0
for page in check_pages:
if not isinstance(page, dict):
raise SystemExit(1)
page_runs = page.get("check_runs") or []
total_count = page.get("total_count")
if not isinstance(page_runs, list) or not isinstance(total_count, int):
raise SystemExit(1)
reported_total = max(reported_total, total_count)
check_runs.extend(page_runs)
if len(check_runs) < reported_total:
raise SystemExit(1)
for run in check_runs:
if not isinstance(run, dict):
raise SystemExit(1)
status = run.get("status")
conclusion = run.get("conclusion")
if status != "completed":
value = "pending"
elif conclusion == "success":
value = "success"
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
value = "failure"
else:
value = "unknown"
combined.append({
"context": run.get("name") or "github-check",
"status": value,
"target_url": run.get("html_url") or run.get("details_url") or "",
})
json.dump({"state": "", "statuses": combined}, sys.stdout)
PY
local status=$?
rm -f "$status_file" "$checks_file"
return "$status"
} }
gitea_get_branch_head_sha() { gitea_get_branch_head_sha() {
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
BRANCH="$2" BRANCH="$2"
shift 2 shift 2
;; ;;
-R|--repo)
TARGET_REPO="$2"
shift 2
;;
--sha)
HEAD_SHA="$2"
shift 2
;;
-t|--timeout) -t|--timeout)
TIMEOUT_SEC="$2" TIMEOUT_SEC="$2"
shift 2 shift 2
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
echo "Error: timeout and interval must be integer seconds." >&2 echo "Error: timeout and interval must be integer seconds." >&2
exit 1 exit 1
fi fi
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
echo "Error: --repo must be OWNER/REPO." >&2
exit 1
fi
OWNER=$(get_repo_owner) if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
REPO=$(get_repo_name) echo "Error: --purpose must be push or merge." >&2
detect_platform > /dev/null exit 1
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
record_cannot_assert "repository-owner-unresolvable"
exit $?
fi
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
record_cannot_assert "repository-name-unresolvable"
exit $?
fi
if ! detect_platform > /dev/null; then
PLATFORM="${PLATFORM:-unknown}"
record_cannot_assert "unsupported-platform"
exit $?
fi
PLATFORM="${PLATFORM:-unknown}" PLATFORM="${PLATFORM:-unknown}"
if [[ -n "$TARGET_REPO" ]]; then
OWNER="${TARGET_REPO%%/*}"
REPO="${TARGET_REPO##*/}"
fi
if [[ -z "$BRANCH" ]]; then
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
record_cannot_assert "current-branch-unresolvable"
exit $?
fi
fi
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required for GitHub CI queue guard." >&2 record_cannot_assert "github-cli-unavailable"
exit 1 exit $?
fi fi
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
if [[ -z "$HEAD_SHA" ]]; then if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2 if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
exit 1 record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi fi
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}" echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
HOST=$(get_remote_host) || { if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
echo "Error: Could not determine remote host." >&2 record_cannot_assert "remote-host-unresolvable"
exit 1 exit $?
} fi
TOKEN=$(get_gitea_token "$HOST") || { if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2 record_cannot_assert "credential-unresolvable"
exit 1 exit $?
}
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi fi
if [[ -z "$HEAD_SHA" ]]; then if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2 if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
exit 1 record_cannot_assert "branch-head-unavailable"
exit $?
fi
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi
if [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi fi
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}" echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
else else
echo "Error: Unsupported platform '${PLATFORM}'." >&2 record_cannot_assert "unsupported-platform"
exit 1 exit $?
fi fi
START_TS=$(date +%s) START_TS=$(date +%s)
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
while true; do while true; do
NOW_TS=$(date +%s) NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then if (( NOW_TS > DEADLINE_TS )); then
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2 echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
exit 124 exit 124
fi fi
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA") if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
else else
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN") if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
fi fi
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json) STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
@@ -271,21 +459,24 @@ while true; do
printf '%s' "$STATUS_JSON" | print_pending_contexts printf '%s' "$STATUS_JSON" | print_pending_contexts
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
terminal-success)
exit 0
;;
no-status) no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2 echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
exit 1 else
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
fi fi
echo "[ci-queue-wait] no status contexts present; proceeding." exit 3
exit 0
;; ;;
terminal-success|terminal-failure|unknown) terminal-failure|malformed|unknown)
# Queue guard only blocks on pending/running/queued states. echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 0 exit 3
;; ;;
*) *)
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively." echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 0 exit 3
;; ;;
esac esac
done done
+496 -102
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub # pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] # Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail set -euo pipefail
@@ -12,8 +12,10 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER="" PR_NUMBER=""
MERGE_METHOD="squash" MERGE_METHOD="squash"
DELETE_BRANCH=false DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
DRY_RUN=false DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
usage() { usage() {
cat <<EOF cat <<EOF
@@ -25,15 +27,18 @@ Options:
-n, --number NUMBER PR number to merge (required) -n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash) -m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge -d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
--dry-run Run metadata/login preflight without merging --dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message -h, --help Show this help message
Examples: Examples:
$(basename "$0") -n 42 # Merge PR #42 $(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge $(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch $(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait $(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
EOF EOF
exit "${1:-1}" exit "${1:-1}"
} }
@@ -53,15 +58,30 @@ while [[ $# -gt 0 ]]; do
DELETE_BRANCH=true DELETE_BRANCH=true
shift shift
;; ;;
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
--dry-run) --dry-run)
DRY_RUN=true DRY_RUN=true
SKIP_QUEUE_GUARD=true
shift shift
;; ;;
--expect-head)
if [[ $# -lt 2 ]]; then
echo "Error: --expect-head requires one full commit SHA." >&2
exit 1
fi
EXPECT_HEAD="$2"
shift 2
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
;;
--escalate-to)
if [[ $# -lt 2 ]]; then
echo "Error: --escalate-to requires one principal name." >&2
exit 1
fi
ESCALATE_TO="$2"
shift 2
;;
-h|--help) -h|--help)
usage 0 usage 0
;; ;;
@@ -86,18 +106,49 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2 echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1 exit 1
fi fi
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
exit 1
fi
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
echo "Error: --escalate-to must be one exact principal name." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")" PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')" BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1 exit 1
fi fi
if [[ -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 [[ "$SKIP_QUEUE_GUARD" != true ]]; then if [[ "$DRY_RUN" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \ "$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \ --purpose merge \
-B "$BASE_BRANCH" \ -B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \ -t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}" -i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi fi
@@ -106,79 +157,442 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner) OWNER=$(get_repo_owner)
REPO=$(get_repo_name) REPO=$(get_repo_name)
is_known_tea_empty_identity_failure() { write_curl_auth_config() {
local error_file="$1" local mode="$1" credential="$2"
printf '%s' "$credential" | python3 -c '
import sys
mode = sys.argv[1]
credential = sys.stdin.read()
if not credential or any(char in credential for char in "\r\n"):
raise SystemExit(1)
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
if mode == "token":
print(f"header = \"Authorization: token {escaped}\"")
elif mode == "basic":
print(f"user = \"{escaped}\"")
else:
raise SystemExit(1)
' "$mode"
}
python3 - "$error_file" <<'PY' LAST_GITEA_HTTP_CODE="000"
import re LAST_GITEA_ERROR=""
MERGE_TEMP_DIRS=()
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
exit 1
fi
done
GITEA_CURL_BOUNDS=(
--max-filesize "$GITEA_CURL_MAX_BYTES"
--max-time "$GITEA_CURL_MAX_TIME"
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
)
format_gitea_error_response() {
local response_file="$1"
python3 - "$response_file" <<'PY'
import json
import sys import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle: with open(sys.argv[1], "rb") as handle:
error = handle.read() raw = handle.read(65536)
try:
known_empty_identity = re.search( response = json.loads(raw.decode("utf-8", errors="replace"))
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]", except (UnicodeDecodeError, json.JSONDecodeError):
error, message = "non-JSON response omitted"
flags=re.IGNORECASE | re.DOTALL, else:
) if isinstance(response, dict):
raise SystemExit(0 if known_empty_identity else 1) message = response.get("message") or response.get("error")
if not message and response.get("errors") is not None:
message = json.dumps(response["errors"], separators=(",", ":"))
else:
message = None
if not message:
message = "JSON response contained no error message"
message = str(message)
if len(message) > 500:
message = message[:500] + "..."
print(ascii(message))
PY PY
} }
merge_gitea_with_api() { cleanup_merge_temp_dirs() {
local host="$1" api_url token basic_auth body_file raw_code payload local path
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" for path in "${MERGE_TEMP_DIRS[@]}"; do
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" [[ -n "$path" ]] && rm -rf -- "$path"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX") done
payload='{"Do":"squash"}' }
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
token=$(get_gitea_token "$host" || true) fetch_gitea_pr_head() {
if [[ -n "$token" ]]; then local host="$1" auth_mode="$2" credential="$3" work_root="$4"
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ local response_file raw_code api_url auth_config curl_rc
-X POST \ response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
-H "User-Agent: curl/8" \ api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
-H "Authorization: token $token" \ if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
-H 'Content-Type: application/json' \ echo "Error: Could not construct Gitea authentication config; refusing request." >&2
-d "$payload" \ rm -f "$response_file"
"$api_url" || true) return 1
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
fi fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
basic_auth=$(get_gitea_basic_auth "$host" || true) -H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
if [[ -n "$basic_auth" ]]; then curl_rc=$?
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ LAST_GITEA_HTTP_CODE="${raw_code:-000}"
-X POST \ if [[ "$curl_rc" -ne 0 ]]; then
-u "$basic_auth" \ LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
-H "User-Agent: curl/8" \ rm -f "$response_file"
-H 'Content-Type: application/json' \ return 1
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
fi fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
rm -f "$response_file"
return 1
fi
if ! python3 - "$response_file" <<'PY'
import json
import re
import sys
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2 with open(sys.argv[1], encoding="utf-8") as handle:
pull = json.load(handle)
head = pull.get("head") if isinstance(pull, dict) else None
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
raise SystemExit(1)
print(sha)
PY
then
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
rm -f "$response_file"
return 1
fi
rm -f "$response_file"
}
fetch_gitea_pr_commits() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
mkdir -p "$work_root"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
return 1
fi
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
printf '[]' > "$combined_file"
page=1
while true; do
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$page_file" "$combined_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
rm -f "$page_file" "$combined_file"
return 1
fi
if ! page_count=$(python3 - "$page_file" <<'PY'
import json import json
import sys import sys
code, path = sys.argv[1], sys.argv[2]
try: with open(sys.argv[1], encoding="utf-8") as handle:
with open(path, encoding="utf-8", errors="replace") as handle: page = json.load(handle)
raw = handle.read(500) if not isinstance(page, list):
data = json.loads(raw) if raw else {} raise SystemExit(1)
message = data.get("message") or data.get("error") or raw or "empty response" print(len(page))
except Exception:
try:
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
except Exception:
message = "unreadable response"
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
PY PY
rm -f "$body_file" ); then
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
rm -f "$page_file" "$combined_file"
return 1
fi
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
combined = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
page = json.load(handle)
json.dump(combined + page, sys.stdout, separators=(",", ":"))
PY
then
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
rm -f "$page_file" "$combined_file" "$merged_file"
return 1
fi
mv "$merged_file" "$combined_file"
rm -f "$page_file"
if [[ "$page_count" -lt 50 ]]; then
break
fi
page=$((page + 1))
if [[ "$page" -gt 1000 ]]; then
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
rm -f "$combined_file"
return 1
fi
done
cat "$combined_file"
rm -f "$combined_file"
}
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
build_coauthor_message_fields() {
local commits_file="$1" context_file="$2" head_file="$3"
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
import json
import re
import sys
commits_path, context_path, head_path = sys.argv[1:]
with open(commits_path, encoding="utf-8") as handle:
commits = json.load(handle)
head_sha = open(head_path, encoding="utf-8").read().strip()
context_parts = open(context_path, "rb").read().split(b"\0")
if len(context_parts) != 4 or context_parts[-1] != b"":
raise SystemExit(1)
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
if not isinstance(commits, list) or not commits:
print(
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
f"Refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not poster:
print(
f"BLOCK: PR poster login is empty; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
print(
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
seen = set()
trailers = []
head_seen = False
for item in commits:
if not isinstance(item, dict):
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
raise SystemExit(75)
sha = str(item.get("sha") or "<unknown>")
if sha == head_sha:
head_seen = True
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
email = str(commit_author.get("email") or "").strip()
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
login = str(provider_author.get("login") or "").strip()
if not login:
diagnostic_email = email or "<missing>"
print(
f"BLOCK: commit {sha!r} has author.login=NULL while "
f"commit.author.email={diagnostic_email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if (
not email.isascii()
or not email.isprintable()
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
):
print(
f"BLOCK: commit {sha!r} has unusable linked identity "
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if login == poster or login in seen:
continue
seen.add(login)
trailers.append(f"Co-authored-by: {login} <{email}>")
if not head_seen:
print(
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not trailers:
print("{}")
raise SystemExit(0)
if not title:
print(
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
print(
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
print(json.dumps({
"MergeTitleField": title,
"MergeMessageField": "\n".join(trailers),
}, separators=(",", ":")))
PY
}
merge_gitea_api_attempt() {
local host="$1" auth_mode="$2" credential="$3"
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
mkdir -p "$work_root"
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
chmod 0700 "$attempt_dir"
MERGE_TEMP_DIRS+=("$attempt_dir")
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
printf '{}' > "$fields_file"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return 75
fi
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
rm -f "$commits_file" "$context_file" "$head_file"
fi
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
fields = json.load(handle)
head_sha, delete_branch = sys.argv[2:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
payload.update(fields)
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
if payload.get("Do") != "squash" or set(payload) - allowed:
raise SystemExit(1)
print(json.dumps(payload, separators=(",", ":")))
PY
then
rm -f "$body_file" "$fields_file" "$payload_file"
return 1
fi
rm -f "$fields_file"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
rm -f "$body_file" "$payload_file"
return 1
fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
-X POST -H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
fi
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
[[ "$raw_code" =~ ^2 ]]
}
merge_gitea_with_api() {
local host="$1" token attempt_rc
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
fi
if [[ -z "$token" ]]; then
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
return 1
fi
if merge_gitea_api_attempt "$host" token "$token"; then
return 0
else
attempt_rc=$?
fi
if [[ "$attempt_rc" -eq 75 ]]; then
return 75
fi
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
return 1
fi
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
return 1 return 1
} }
@@ -188,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2 echo "Error: Cannot determine host from origin remote URL" >&2
exit 1 exit 1
} }
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
if [[ -n "$TEA_LOGIN" ]]; then echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
else else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)." echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
fi fi
else else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)." echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
@@ -202,7 +615,11 @@ fi
case "$PLATFORM" in case "$PLATFORM" in
github) github)
cmd=(gh pr merge "$PR_NUMBER" --squash) if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
exit 1
fi
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch) [[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}" "${cmd[@]}"
;; ;;
@@ -211,32 +628,9 @@ case "$PLATFORM" in
echo "Error: Cannot determine host from origin remote URL" >&2 echo "Error: Cannot determine host from origin remote URL" >&2
exit 1 exit 1
} }
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" # Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so every Gitea merge uses the authenticated API path.
if [[ -n "$TEA_LOGIN" ]]; then merge_gitea_with_api "$HOST"
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
cat "$TEA_ERROR_FILE" >&2
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
rm -f "$TEA_ERROR_FILE"
merge_gitea_with_api "$HOST"
else
cat "$TEA_ERROR_FILE" >&2
rm -f "$TEA_ERROR_FILE"
exit 1
fi
else
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
merge_gitea_with_api "$HOST"
fi
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
fi
;; ;;
*) *)
echo "Error: Could not detect git platform" >&2 echo "Error: Could not detect git platform" >&2
@@ -109,7 +109,7 @@ PY
detect_platform > /dev/null detect_platform > /dev/null
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft) METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
write_metadata "$METADATA" write_metadata "$METADATA"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
OWNER=$(get_repo_owner) OWNER=$(get_repo_owner)
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
data.get('head_ref'), data.get('head_ref'),
head_ref, head_ref,
) )
head_sha = first_non_empty(
nested(data, 'head', 'sha'),
nested(data, 'head', 'id'),
data.get('head_sha'),
)
head_repo = first_non_empty(
nested(data, 'head', 'repo', 'full_name'),
nested(data, 'head', 'repo', 'name_with_owner'),
)
if not head_repo:
head_repo_owner = first_non_empty(
nested(data, 'head', 'repo', 'owner', 'login'),
nested(data, 'head', 'repo', 'owner', 'username'),
nested(data, 'head', 'repo', 'owner_name'),
)
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
if head_repo_owner and head_repo_name:
head_repo = f'{head_repo_owner}/{head_repo_name}'
base_ref = first_non_empty( base_ref = first_non_empty(
nested(data, 'base', 'ref'), nested(data, 'base', 'ref'),
nested(data, 'base', 'name'), nested(data, 'base', 'name'),
@@ -207,6 +226,8 @@ normalized = {
'state': data.get('state'), 'state': data.get('state'),
'author': nested(data, 'user', 'login') or '', 'author': nested(data, 'user', 'login') or '',
'headRefName': head_ref, 'headRefName': head_ref,
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref, 'baseRefName': base_ref,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)], 'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)], 'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
@@ -13,7 +13,7 @@
# Covers: # Covers:
# (a) 404 branch-absent -> exit 0, "queue clear" message. # (a) 404 branch-absent -> exit 0, "queue clear" message.
# (b) 200 existing branch + a terminal CI state -> unchanged behavior. # (b) 200 existing branch + a terminal CI state -> unchanged behavior.
# (c) genuine API error (500) -> still fail-closed (nonzero exit). # (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
set -euo pipefail set -euo pipefail
@@ -62,7 +62,7 @@ case "$mode" in
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;; 200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
500) code=500; body='{"message":"internal server error"}' ;; 500) code=500; body='{"message":"internal server error"}' ;;
no-status) code=200; body='{}' ;; no-status) code=200; body='{}' ;;
terminal-success) code=200; body='{"state":"success"}' ;; terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
*) *)
echo "curl stub: unknown mode=$mode" >&2 echo "curl stub: unknown mode=$mode" >&2
exit 2 exit 2
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export GITEA_TOKEN="stub-token" export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test" export GITEA_URL="https://git.example.test"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1 "$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
) )
} }
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
fail=1 fail=1
fi fi
# (c) genuine API error (500) -> still fail-closed, exit nonzero. # (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
set +e set +e
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1) out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
status_c=$? status_c=$?
set -e set -e
if [[ "$status_c" -eq 0 ]]; then if [[ "$status_c" -ne 0 ]]; then
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2 echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
echo "$out_c" >&2 echo "$out_c" >&2
fail=1 fail=1
elif [[ "$out_c" == *"queue clear"* ]]; then elif [[ "$out_c" == *"queue clear"* ]]; then
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2 echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
echo "$out_c" >&2 echo "$out_c" >&2
fail=1 fail=1
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
fail=1
fi fi
if [[ "$fail" -eq 0 ]]; then if [[ "$fail" -eq 0 ]]; then
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b fix/github-checks
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
cat > "$STUB_DIR/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
endpoint=""
for arg in "$@"; do
[[ "$arg" == repos/* ]] && endpoint="$arg"
done
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
case "$endpoint" in
repos/acme/widgets/branches/fix/github-checks)
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
;;
repos/acme/widgets/commits/*/statuses?per_page=100)
printf '%s\n' '[[]]'
;;
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
case "${MOSAIC_GH_CHECK_MODE:?}" in
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
*) exit 2 ;;
esac
;;
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/gh"
run_guard() {
local mode="$1"
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_GH_CHECK_MODE="$mode"
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
)
}
failures=0
assert_case() {
local mode="$1" expected_rc="$2" expected_state="$3" output rc
set +e
output=$(run_guard "$mode" 2>&1)
rc=$?
set -e
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
failures=$((failures + 1))
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
failures=$((failures + 1))
fi
if [[ "$output" != *"state=$expected_state"* ]]; then
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/gh-calls.log"
assert_case success zero terminal-success
assert_case pending nonzero pending
assert_case failure nonzero terminal-failure
assert_case late-failure nonzero terminal-failure
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
echo "FAIL: expected every case to query all Checks API pages" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "GitHub check-runs regression failed ($failures assertions)" >&2
exit 1
fi
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
@@ -0,0 +1,364 @@
#!/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"
STATUS_OBSERVED="$WORK_DIR/status-observed"
CLOCK_LOG="$WORK_DIR/clock.log"
WATCHDOG_PYTHON="/usr/bin/python3"
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
WATCHDOG_TIMEOUT_SEC=5
WATCHDOG_EXIT=90
FEATURE_BRANCH="fix/rm-03-fixture"
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
exit 1
fi
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
cat > "$WATCHDOG_SCRIPT" <<'PY'
import os
import signal
import subprocess
import sys
if len(sys.argv) < 3:
raise SystemExit(2)
timeout_seconds = float(sys.argv[1])
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
print(
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
"before completing its intended path",
file=sys.stderr,
)
raise SystemExit(90)
if return_code < 0:
raise SystemExit(128 - return_code)
raise SystemExit(return_code)
PY
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}" == "hang-before-provider" ]]; then
while :; do :; done
fi
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)
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
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
cat > "$STUB_DIR/date" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
echo "unexpected date invocation: $*" >&2
exit 2
fi
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1002\n'
else
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1000\n'
fi
SH
cat > "$STUB_DIR/sleep" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
SH
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
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
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
# Provider observation is the synchronization event. The one-second
# timeout is subject semantics under virtual time, never a wall wait.
# The absolute Python runtime uses an internal monotonic wait and kills
# the subject's isolated process group. Neither operation can resolve
# to the virtual date/sleep stubs at the front of PATH.
local subject_rc
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
subject_rc=0
else
subject_rc=$?
fi
return "$subject_rc"
)
}
failures=0
assert_provider_observed() {
local name="$1" require_expiration="${2:-0}"
if [[ ! -e "$STATUS_OBSERVED" ]]; then
echo "FAIL $name: status provider was not observed" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
failures=$((failures + 1))
fi
if [[ "$require_expiration" -eq 1 ]]; then
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
echo "FAIL $name: pending path did not expire after provider observation" >&2
failures=$((failures + 1))
fi
fi
}
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
if [[ "$status_mode" != "credential-unresolvable" ]]; then
if [[ "$status_mode" == "pending" ]]; then
assert_provider_observed "$name" 1
else
assert_provider_observed "$name"
fi
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'
# Positive liveness control: a subject mutant hangs before the branch lookup
# can reach the status provider. Only the independent real-clock watchdog may
# terminate it, and its failure must be distinct from subject timeout rc=124.
set +e
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
watchdog_rc=$?
set -e
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
failures=$((failures + 1))
fi
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
printf '%s\n' "$watchdog_output" >&2
failures=$((failures + 1))
fi
if [[ -e "$STATUS_OBSERVED" ]]; then
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
failures=$((failures + 1))
fi
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
assert_provider_observed merge-provider-unreachable
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
assert_provider_observed audit-unavailable
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback. # Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
set -euo pipefail set -euo pipefail
@@ -51,22 +51,23 @@ for arg in "$@"; do
prev="" prev=""
continue continue
fi fi
if [[ "$prev" == "-d" ]]; then if [[ "$prev" == "data" ]]; then
post_data="$arg" post_data="$arg"
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
prev="" prev=""
continue continue
fi fi
if [[ "$arg" == "-o" ]]; then if [[ "$prev" == "config" ]]; then
prev="-o" [[ "$arg" == "-" ]] && cat >/dev/null
prev=""
continue continue
fi fi
if [[ "$arg" == "-d" ]]; then case "$arg" in
prev="-d" -o) prev="-o" ;;
continue -d|--data|--data-binary) prev="data" ;;
fi -K|--config) prev="config" ;;
if [[ "$arg" == "-w" ]]; then -w) write_code=true ;;
write_code=true esac
fi
done done
emit_response() { emit_response() {
local body="$1" local body="$1"
@@ -79,15 +80,24 @@ emit_response() {
printf '200' printf '200'
fi fi
} }
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
exit 0
fi
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
exit 0 exit 0
fi fi
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
if [[ "$post_data" != '{"Do":"squash"}' ]]; then POST_DATA="$post_data" python3 - <<'PY'
echo "unexpected merge payload: $post_data" >&2 import json
exit 96 import os
fi payload = json.loads(os.environ["POST_DATA"])
assert payload == {
"Do": "squash",
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
}, payload
PY
emit_response '{"merged":true,"message":"mock merge complete"}' emit_response '{"merged":true,"message":"mock merge complete"}'
exit 0 exit 0
fi fi
@@ -107,8 +117,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token" export GITEA_TOKEN="redacted-test-token"
OUTPUT="$SANDBOX/output.log" OUTPUT="$SANDBOX/output.log"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2 echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
echo "--- output ---" >&2 echo "--- output ---" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
echo "--- mock log ---" >&2 echo "--- mock log ---" >&2
@@ -127,38 +137,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
exit 1 exit 1
fi fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
printf '\n' >> "$PR_MERGE_TEST_LOG"
if [[ "$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "$*" == *"pr merge"* ]]; then
echo 'tea network timeout' >&2
exit 2
fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
: > "$LOG_FILE"
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected arbitrary tea failure to remain blocking." >&2
exit 1
fi
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
fi
if ! grep -q 'tea network timeout' "$OUTPUT"; then
echo "Expected arbitrary tea error to be preserved in output." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF' cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
@@ -177,8 +155,8 @@ EOF
chmod +x "$MOCK_BIN/tea" chmod +x "$MOCK_BIN/tea"
unset GITEA_LOGIN unset GITEA_LOGIN
: > "$LOG_FILE" : > "$LOG_FILE"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2 echo "Expected the exact-head API path not to depend on a tea login." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1 exit 1
@@ -215,7 +193,7 @@ cd "$REPO_DIR"
git remote set-url origin https://github.com/mosaicstack/stack.git git remote set-url origin https://github.com/mosaicstack/stack.git
: > "$LOG_FILE" : > "$LOG_FILE"
rm -f "$SENTINEL" rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected GitHub metacharacter PR number to be rejected." >&2 echo "Expected GitHub metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1 exit 1
@@ -240,7 +218,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
export GITEA_LOGIN="git.mosaicstack.dev" export GITEA_LOGIN="git.mosaicstack.dev"
: > "$LOG_FILE" : > "$LOG_FILE"
rm -f "$SENTINEL" rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected Gitea metacharacter PR number to be rejected." >&2 echo "Expected Gitea metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1 exit 1
@@ -260,4 +238,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
exit 1 exit 1
fi fi
echo "pr-merge.sh Gitea fallback regression passed" echo "pr-merge.sh Gitea exact-head API regression passed"
@@ -0,0 +1,173 @@
#!/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=""
out_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--data|--data-binary)
payload="$2"
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
shift 2
;;
-o)
out_file="$2"
shift 2
;;
-K|--config)
[[ "$2" == "-" ]] && cat >/dev/null
shift 2
;;
-w|-X|-H)
shift 2
;;
*) shift ;;
esac
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
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,541 @@
#!/usr/bin/env bash
# Regression harness for the optional, identity-checked Gitea squash message.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
ORIG_PATH="$PATH"
failures=0
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
fail() {
echo "FAIL $1" >&2
failures=$((failures + 1))
}
make_case() {
local name="$1" case_dir
case_dir="$WORK_DIR/$name"
mkdir -p "$case_dir/bin" "$case_dir/agent"
cp "$SUBJECT" "$case_dir/pr-merge.sh"
chmod +x "$case_dir/pr-merge.sh"
cat > "$case_dir/detect-platform.sh" <<'SH'
#!/usr/bin/env bash
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
get_repo_owner() { printf 'acme\n'; }
get_repo_name() { printf 'widgets\n'; }
get_remote_host() { printf 'git.example.test\n'; }
get_gitea_token() {
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
return 1
fi
printf 'fixture-token\n'
}
get_gitea_basic_auth() {
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
printf 'fixture-user:fixture-password\n'
return "${MOSAIC_TEST_BASIC_RC:-0}"
fi
return 1
}
get_gitea_login_for_host() { return 1; }
SH
cat > "$case_dir/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
else
title='Preserve both branch authors'
fi
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
SH
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
cat > "$case_dir/bin/python3" <<'SH'
#!/usr/bin/env bash
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
SH
cat > "$case_dir/bin/curl" <<'SH'
#!/usr/bin/env bash
set -eu
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
url=""
method="GET"
out_file=""
data=""
config=""
auth_mode="none"
has_max_filesize=0
has_max_time=0
has_connect_timeout=0
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
out_file="$2"
shift 2
;;
-w)
shift 2
;;
-X)
method="$2"
shift 2
;;
-d|--data|--data-binary)
data="$2"
if [[ "$data" == @* ]]; then
data=$(<"${data#@}")
fi
shift 2
;;
-K|--config)
if [[ "$2" == "-" ]]; then
config=$(cat)
fi
shift 2
;;
--max-filesize)
has_max_filesize=1
shift 2
;;
--max-time)
has_max_time=1
shift 2
;;
--connect-timeout)
has_connect_timeout=1
shift 2
;;
-H|--header|-u|--user)
if [[ "$2" == *"fixture-token"* ]]; then
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
fi
if [[ "$2" == *"fixture-password"* ]]; then
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
fi
shift 2
;;
http://*|https://*)
url="$1"
shift
;;
*)
shift
;;
esac
done
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
auth_mode="token"
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
auth_mode="basic"
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
fi
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
case "$url" in
*/pulls/42)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
head_sha=4444444444444444444444444444444444444444
fi
body="{\"head\":{\"sha\":\"$head_sha\"}}"
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/commits*)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified)
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
else
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
fi
;;
null-login)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
;;
unsafe-identity)
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
single)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
*)
echo "unknown commits mode" >&2
exit 2
;;
esac
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/merge)
body='{}'
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
body='{"message":"branch policy rejected\n\u001b[31m"}'
code=409
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
body='{"message":"permission denied"}'
code=403
else
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
fi
;;
*/users/*)
body='{"message":"not found"}'
code=404
;;
*)
body='{"message":"unexpected URL"}'
code=500
;;
esac
if [[ -n "$out_file" ]]; then
printf '%s' "$body" > "$out_file"
else
printf '%s' "$body"
fi
printf '%s' "$code"
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
oversize) exit 63 ;;
stalled) exit 28 ;;
esac
SH
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
printf '%s\n' "$case_dir"
}
run_case() {
local case_dir="$1" mode="$2"
shift 2
MOSAIC_TEST_COMMITS_MODE="$mode" \
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
AGENT_WORK_ROOT="$case_dir/agent" \
PATH="$case_dir/bin:$ORIG_PATH" \
"$case_dir/pr-merge.sh" -n 42 "$@"
}
# Verified multi-author path: the non-poster trailer is built from one commit's
# linked author.login and that same commit's author email. No /users lookup.
verified_dir=$(make_case verified)
set +e
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
verified_rc=$?
set -e
if [[ "$verified_rc" -ne 0 ]]; then
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
fail "verified multi-author merge did not reach the API payload"
else
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "2222222222222222222222222222222222222222",
"MergeTitleField": "Preserve both branch authors",
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
}, payload
PY
fi
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
fail "verified path performed a forbidden second /users lookup"
fi
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
fi
# A linked email containing a terminal escape must block before mutation.
escape_email_dir=$(make_case escape-email)
set +e
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
escape_email_rc=$?
set -e
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
# Curl transfer and duration failures must remain failures even with HTTP 200.
for failure_mode in oversize stalled; do
failure_dir=$(make_case "curl-$failure_mode")
set +e
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
failure_rc=$?
set -e
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
done
# The authenticated head is re-read under the mutation credential but cannot
# replace the canonical preflight/review head. A move blocks before enumeration
# or mutation even though the provider returned a valid new SHA.
moved_dir=$(make_case moved-head)
set +e
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
moved_rc=$?
set -e
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
# Token resolution failure is not an authentication response. It must fail
# closed instead of borrowing a Basic credential under a different principal.
token_missing_dir=$(make_case token-missing)
set +e
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
run_case "$token_missing_dir" single 2>&1)
token_missing_rc=$?
set -e
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
# A failed Basic resolver must never use its nonempty output or reach mutation.
basic_rc_dir=$(make_case basic-resolver-rc)
set +e
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
basic_rc_rc=$?
set -e
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
# HTTP 401 never changes principals: inspection rejection fails closed without
# resolving or attempting Basic Auth.
fallback_inspect_dir=$(make_case fallback-inspection)
set +e
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_inspect_rc=$?
set -e
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
# Token rejection at merge likewise fails closed without cross-principal retry.
fallback_merge_dir=$(make_case fallback-merge)
set +e
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_merge_rc=$?
set -e
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
# BLOCK path: a commit email exists but author.login is null. It must name both
# facts, name the escalation principal, and never reach the merge endpoint.
null_dir=$(make_case null-login)
set +e
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
null_rc=$?
set -e
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
# including an invalid non-head SHA that contains control characters.
unsafe_dir=$(make_case unsafe-identity)
set +e
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
unsafe_rc=$?
set -e
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
# The provider PR title cannot add an unchecked trailer outside the constructed
# message field: multi-line and trailer-shaped titles block before mutation.
title_dir=$(make_case title-injection)
set +e
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
title_rc=$?
set -e
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
# Provider failures remain diagnosable after their temporary response file is
# removed, but provider-controlled control characters stay log-safe.
error_dir=$(make_case provider-error)
set +e
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
run_case "$error_dir" single 2>&1)
error_rc=$?
set -e
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
# Authorization denials likewise fail closed instead of changing principals.
forbidden_dir=$(make_case forbidden)
set +e
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
run_case "$forbidden_dir" single 2>&1)
forbidden_rc=$?
set -e
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
# The BLOCK destination cannot be generic or inferred after failure: opting in
# without a named principal is refused before any provider operation.
principal_dir=$(make_case missing-principal)
set +e
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
principal_rc=$?
set -e
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
# A trailing value-taking option receives a stable CLI diagnostic instead of a
# set -u unbound-variable crash.
value_dir=$(make_case missing-principal-value)
set +e
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
value_rc=$?
set -e
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
# Negative control: ordinary single-author merge remains byte-for-byte payload
# compatible and hardcoded to squash, with no optional message fields.
single_dir=$(make_case single)
set +e
single_output=$(run_case "$single_dir" single 2>&1)
single_rc=$?
set -e
if [[ "$single_rc" -ne 0 ]]; then
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
fail "ordinary single-author merge did not reach the API payload"
else
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "1111111111111111111111111111111111111111",
}, payload
PY
fi
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
# Squash is not defaultable: an explicit non-squash method must remain refused.
method_dir=$(make_case method-refusal)
set +e
method_output=$(run_case "$method_dir" single -m merge 2>&1)
method_rc=$?
set -e
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
if [[ "$failures" -ne 0 ]]; then
echo "pr-merge message-field regression failed ($failures assertions)" >&2
exit 1
fi
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
@@ -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"
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
# loop — which would make the control a false negative. A root dotfile is # loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit. # operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh" STRIPPED="$FW/.install-rollback-control.tmp.sh"
SIGNALED="$FW/.install-signal-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh" NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh" D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh" D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL" rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0 pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; } chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ────── # ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the # A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting # interrupt — restoring the snapshot, then resuming the sync and reporting
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never # success. The earlier test used a child cp shim to signal its parent, making
# fires and ONLY the signal path governs), and assert the shipped installer # child completion race Bash's interrupted wait. Concurrency is not part of the
# restores AND exits without reporting success. The control strips `exit 1` from # guarded property: sync_framework_keep() runs in the installer's own Bash
# the trap and shows the buggy resume-to-success. # process, and `kill` is a builtin. Generate two installer fixtures that signal
make_term_shim() { # themselves at the same known mid-sync point. Their TERM handlers emit the same
local dir="$1" # observable before diverging, so missing signal delivery fails BOTH arms rather
cat > "$dir/cp" <<SHIM # than manufacturing a pass. The only semantic difference between fixtures is
#!/usr/bin/env bash # the explicit `exit 1` whose load-bearing behavior this control proves.
dest="\${@: -1}" TERM_MARKER='[test-control] TERM handler entered'
case "\$dest" in HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
*/$POISON_REL) HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;; make_signal_installer() {
esac local output="$1" handler="$2"
exec env PATH="$ORIG_PATH" cp "\$@" local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
SHIM local target_cp=' cp "$abs" "$dst/$rel"'
chmod +x "$dir/cp" local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
local inject_close=' fi'
if ! awk \
-v target_trap="$target_trap" -v target_cp="$target_cp" \
-v handler="$handler" -v inject_open="$inject_open" \
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
$0 == target_cp {
print inject_open
print inject_kill
print inject_close
injection_sites++
}
{ print }
$0 == target_trap {
print handler
handler_sites++
}
END {
if (handler_sites != 1 || injection_sites != 1) exit 42
}
' "$INSTALL" > "$output"; then
rm -f "$output"
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
exit 1
fi
chmod +x "$output"
} }
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>". make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
signal_fixture_ready() {
local fixture="$1" expected_handler="$2"
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
&& grep -Fqx "$expected_handler" "$fixture"
}
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
"signaled_fixture_ready"
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
"noexit_fixture_ready"
chk "[control] removing the explicit TERM exit changes the fixture" \
"! cmp -s '$SIGNALED' '$NOEXIT'"
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
# the selected copy. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() { run_signal_upgrade() {
local installer="$1" H OUT SHIM rc local installer="$1" H OUT rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d) H=$(mktemp -d); OUT=$(mktemp)
seed_home "$H" seed_home "$H"
make_term_shim "$SHIM"
set +e set +e
PATH="$SHIM:$ORIG_PATH" \ PATH="$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1 MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$? rc=$?
set -e 2>/dev/null || true set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H" printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
} }
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL") IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
chk "[signal] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \ chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]" "[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \ chk "[signal] restore_snapshot fires on the interrupt" \
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
chk "[signal] does NOT resume to report sync success after the interrupt" \ chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'" "! grep -q 'file phase complete' '$OUTC'"
# Control: strip `exit 1` from the signal trap → the handler returns, the script IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves. chk "[control] TERM handler observable fires exactly once" \
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \ "[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
"$INSTALL" > "$NOEXIT" chk "[control] without 'exit 1' the handler restores before returning" \
chk "[control] the exit-strip actually changed the installer" \ "grep -q 'restoring previous state from snapshot' '$OUTD'"
"! cmp -s '$INSTALL' '$NOEXIT'" chk "[control] without 'exit 1' the installer exits zero after resuming" \
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT") "[ '$rcD' -eq 0 ]"
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \ chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'" "grep -q 'file phase complete' '$OUTD'"
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned). # Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap). # Cleanup (generated installer controls are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \ rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL" "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo echo
echo "RESULT: $pass passed, $fail failed" echo "RESULT: $pass passed, $fail failed"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.48", "version": "0.0.49",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/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-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",