diff --git a/docs/scratchpads/rm-03-queue-guard.md b/docs/scratchpads/rm-03-queue-guard.md new file mode 100644 index 00000000..f2cc3d8c --- /dev/null +++ b/docs/scratchpads/rm-03-queue-guard.md @@ -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. diff --git a/packages/mosaic/framework/guides/CI-CD-PIPELINES.md b/packages/mosaic/framework/guides/CI-CD-PIPELINES.md index cd84c947..9616f18b 100644 --- a/packages/mosaic/framework/guides/CI-CD-PIPELINES.md +++ b/packages/mosaic/framework/guides/CI-CD-PIPELINES.md @@ -925,14 +925,16 @@ Woodpecker note: Before pushing a branch or merging a PR, guard against overlapping project pipelines: ```bash -~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main -~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main +~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push +~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha ``` Behavior: -- If pipeline state is running/queued/pending, wait until queue clears. -- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop. +- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero. +- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero. +- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero. +- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs. ## Gitea as Unified Platform diff --git a/packages/mosaic/framework/guides/CODE-REVIEW.md b/packages/mosaic/framework/guides/CODE-REVIEW.md index 10ac1e8b..938915d8 100755 --- a/packages/mosaic/framework/guides/CODE-REVIEW.md +++ b/packages/mosaic/framework/guides/CODE-REVIEW.md @@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE): - PR target for delivery is `main`. - Direct pushes to `main` are prohibited. - Merge to `main` MUST be squash-only. -- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent). +- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent). ## Review Checklist diff --git a/packages/mosaic/framework/guides/E2E-DELIVERY.md b/packages/mosaic/framework/guides/E2E-DELIVERY.md index dbf40706..b41a34be 100644 --- a/packages/mosaic/framework/guides/E2E-DELIVERY.md +++ b/packages/mosaic/framework/guides/E2E-DELIVERY.md @@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order: 8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. `push` - push immediately after queue guard passes. 10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers. -11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`. +11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow). 13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable). 14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes). @@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order: > the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds > without asking. -1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main` -2. `~/.config/mosaic/tools/git/pr-merge.sh -n -m squash` +1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha ` +2. `~/.config/mosaic/tools/git/pr-merge.sh -n -m squash --expect-head ` 3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n ` 4. `~/.config/mosaic/tools/git/issue-close.sh -i ` (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. diff --git a/packages/mosaic/framework/guides/ORCHESTRATOR.md b/packages/mosaic/framework/guides/ORCHESTRATOR.md index 1ed9a138..076f7a6a 100644 --- a/packages/mosaic/framework/guides/ORCHESTRATOR.md +++ b/packages/mosaic/framework/guides/ORCHESTRATOR.md @@ -425,11 +425,11 @@ git push and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable. 13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks): - Before merging, run queue guard: - `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main` + `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha ` - Ensure PR exists for the task branch (create/update via wrappers if needed): `~/.config/mosaic/tools/git/pr-create.sh ... -B main` - Merge via wrapper: - `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` + `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` - Wait for terminal CI status: `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}` - Close linked issue after merge + green CI: @@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool: **MANDATORY:** This ALWAYS includes linting. If the project has a linter configured (ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched. -Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below) +Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below) ## Git Scripts @@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh): - `~/.config/mosaic/tools/git/issue-view.sh -i {N}` - `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main` -- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main` -- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` +- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}` +- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}` +- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` - `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}` - `~/.config/mosaic/tools/git/issue-close.sh -i {N}` diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 74ce461b..0eca6c5a 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a # Milestones ~/.config/mosaic/tools/git/milestone-create.sh -# CI queue guard (required before push/merge) +# CI queue guard (required before push/merge; defaults to the checked-out branch) ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ``` +The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. + ### Code Review (Codex) ```bash diff --git a/packages/mosaic/framework/templates/agent/AGENTS.md.template b/packages/mosaic/framework/templates/agent/AGENTS.md.template index 14dfc1cf..86d18d01 100755 --- a/packages/mosaic/framework/templates/agent/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -88,7 +88,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/CLAUDE.md.template index 937de770..b2715a6d 100755 --- a/packages/mosaic/framework/templates/agent/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/CLAUDE.md.template @@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template index b566accb..4c6b2893 100755 --- a/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -97,7 +97,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template index 306b3e1f..4cd3e8d8 100755 --- a/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template @@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template index 85aad51e..b423d545 100755 --- a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -101,7 +101,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template index b2cbb759..12d47883 100755 --- a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template @@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template index 2205eb59..296bf2d6 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -87,7 +87,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template index 036651d7..afd3fa2a 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template @@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template index 2557b6e0..bf3830ab 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -84,7 +84,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template index f671359a..1be2922b 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template @@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template index 0deed88e..31315cd6 100755 --- a/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -85,7 +85,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template index 9843d675..ed9b0715 100755 --- a/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template @@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index f1cd6825..d4f49b87 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -7,7 +7,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" -BRANCH="main" +BRANCH="" +TARGET_REPO="" +HEAD_SHA="" TIMEOUT_SEC=900 INTERVAL_SEC=15 PURPOSE="merge" @@ -15,10 +17,12 @@ REQUIRE_STATUS=0 usage() { cat <&2 + return 70 + fi + + if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY' +import datetime +import json +import os +import sys + +path, reason, platform, purpose, branch, repo = sys.argv[1:] +record = { + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "outcome": "CANNOT_ASSERT", + "reason": reason, + "platform": platform, + "purpose": purpose, + "disposition": "hold" if purpose == "merge" else "degraded-pass", + "branch": branch, + "repo": repo, +} +fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) +try: + os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode()) +finally: + os.close(fd) PY + then + echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2 + return 70 + fi + + if [[ "$PURPOSE" == "merge" ]]; then + echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2 + return 75 + fi + + echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2 + return 0 } github_get_branch_head_sha() { @@ -128,7 +178,87 @@ github_get_commit_status_json() { local owner="$1" local repo="$2" local sha="$3" - gh api "repos/${owner}/${repo}/commits/${sha}/status" + local work_root status_file checks_file + work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}" + mkdir -p "$work_root" || return 1 + status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1 + checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || { + rm -f "$status_file" + return 1 + } + + if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" || + ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then + rm -f "$status_file" "$checks_file" + return 1 + fi + + python3 - "$status_file" "$checks_file" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + status_pages = json.load(handle) +with open(sys.argv[2], encoding="utf-8") as handle: + check_pages = json.load(handle) + +if not isinstance(status_pages, list) or not isinstance(check_pages, list): + raise SystemExit(1) + +# The statuses endpoint is newest-first and can contain retries for one context. +# Keep only the newest entry per context after flattening every page. +combined = [] +seen_contexts = set() +for page in status_pages: + if not isinstance(page, list): + raise SystemExit(1) + for status in page: + if not isinstance(status, dict): + raise SystemExit(1) + context = status.get("context") + if not isinstance(context, str) or not context or context in seen_contexts: + continue + seen_contexts.add(context) + combined.append(status) + +check_runs = [] +reported_total = 0 +for page in check_pages: + if not isinstance(page, dict): + raise SystemExit(1) + page_runs = page.get("check_runs") or [] + total_count = page.get("total_count") + if not isinstance(page_runs, list) or not isinstance(total_count, int): + raise SystemExit(1) + reported_total = max(reported_total, total_count) + check_runs.extend(page_runs) +if len(check_runs) < reported_total: + raise SystemExit(1) + +for run in check_runs: + if not isinstance(run, dict): + raise SystemExit(1) + status = run.get("status") + conclusion = run.get("conclusion") + if status != "completed": + value = "pending" + elif conclusion == "success": + value = "success" + elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}: + value = "failure" + else: + value = "unknown" + combined.append({ + "context": run.get("name") or "github-check", + "status": value, + "target_url": run.get("html_url") or run.get("details_url") or "", + }) + +json.dump({"state": "", "statuses": combined}, sys.stdout) +PY + local status=$? + rm -f "$status_file" "$checks_file" + return "$status" } gitea_get_branch_head_sha() { @@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do BRANCH="$2" shift 2 ;; + -R|--repo) + TARGET_REPO="$2" + shift 2 + ;; + --sha) + HEAD_SHA="$2" + shift 2 + ;; -t|--timeout) TIMEOUT_SEC="$2" shift 2 @@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th echo "Error: timeout and interval must be integer seconds." >&2 exit 1 fi +if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2 + exit 1 +fi +if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then + echo "Error: --repo must be OWNER/REPO." >&2 + exit 1 +fi -OWNER=$(get_repo_owner) -REPO=$(get_repo_name) -detect_platform > /dev/null +if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then + echo "Error: --purpose must be push or merge." >&2 + exit 1 +fi + +OWNER="unknown" +REPO="unknown" +PLATFORM="unknown" +if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then + record_cannot_assert "repository-owner-unresolvable" + exit $? +fi +if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then + record_cannot_assert "repository-name-unresolvable" + exit $? +fi +if ! detect_platform > /dev/null; then + PLATFORM="${PLATFORM:-unknown}" + record_cannot_assert "unsupported-platform" + exit $? +fi PLATFORM="${PLATFORM:-unknown}" +if [[ -n "$TARGET_REPO" ]]; then + OWNER="${TARGET_REPO%%/*}" + REPO="${TARGET_REPO##*/}" +fi + +if [[ -z "$BRANCH" ]]; then + if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then + record_cannot_assert "current-branch-unresolvable" + exit $? + fi +fi + if [[ "$PLATFORM" == "github" ]]; then if ! command -v gh >/dev/null 2>&1; then - echo "Error: gh CLI is required for GitHub CI queue guard." >&2 - exit 1 + record_cannot_assert "github-cli-unavailable" + exit $? fi - HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") if [[ -z "$HEAD_SHA" ]]; then - echo "Error: Could not resolve ${BRANCH} head SHA." >&2 - exit 1 + if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then + record_cannot_assert "branch-head-unavailable" + exit $? + fi fi echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}" elif [[ "$PLATFORM" == "gitea" ]]; then - HOST=$(get_remote_host) || { - echo "Error: Could not determine remote host." >&2 - exit 1 - } - TOKEN=$(get_gitea_token "$HOST") || { - echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2 - exit 1 - } - HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN") - if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then - echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear." - exit 0 + if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then + record_cannot_assert "remote-host-unresolvable" + exit $? + fi + if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then + record_cannot_assert "credential-unresolvable" + exit $? fi if [[ -z "$HEAD_SHA" ]]; then - echo "Error: Could not resolve ${BRANCH} head SHA." >&2 - exit 1 + if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then + record_cannot_assert "branch-head-unavailable" + exit $? + fi + if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then + echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear." + exit 0 + fi + if [[ -z "$HEAD_SHA" ]]; then + record_cannot_assert "branch-head-unavailable" + exit $? + fi fi echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}" else - echo "Error: Unsupported platform '${PLATFORM}'." >&2 - exit 1 + record_cannot_assert "unsupported-platform" + exit $? fi START_TS=$(date +%s) @@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC)) while true; do NOW_TS=$(date +%s) if (( NOW_TS > DEADLINE_TS )); then - echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2 + echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2 exit 124 fi if [[ "$PLATFORM" == "github" ]]; then - STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA") + if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then + record_cannot_assert "status-provider-unreachable" + exit $? + fi else - STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN") + if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then + record_cannot_assert "status-provider-unreachable" + exit $? + fi fi STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json) @@ -271,21 +459,24 @@ while true; do printf '%s' "$STATUS_JSON" | print_pending_contexts sleep "$INTERVAL_SEC" ;; + terminal-success) + exit 0 + ;; no-status) if [[ "$REQUIRE_STATUS" -eq 1 ]]; then - echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2 - exit 1 + echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2 + else + echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2 fi - echo "[ci-queue-wait] no status contexts present; proceeding." - exit 0 + exit 3 ;; - terminal-success|terminal-failure|unknown) - # Queue guard only blocks on pending/running/queued states. - exit 0 + terminal-failure|malformed|unknown) + echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2 + exit 3 ;; *) - echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively." - exit 0 + echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2 + exit 3 ;; esac done diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 222260c6..403ac056 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -1,6 +1,6 @@ #!/bin/bash # pr-merge.sh - Merge pull requests on Gitea or GitHub -# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard] +# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] set -euo pipefail @@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh" PR_NUMBER="" MERGE_METHOD="squash" DELETE_BRANCH=false -SKIP_QUEUE_GUARD=false DRY_RUN=false +EXPECT_HEAD="" usage() { cat <&2 exit 1 fi +if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2 + exit 1 +fi PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")" BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')" +HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')" +HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')" +HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')" if [[ "$BASE_BRANCH" != "main" ]]; then echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 exit 1 fi -if [[ "$SKIP_QUEUE_GUARD" != true ]]; then +if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2 + exit 1 +fi +if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then + echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2 + exit 1 +fi + +if [[ "$DRY_RUN" != true ]]; then "$SCRIPT_DIR/ci-queue-wait.sh" \ --purpose merge \ - -B "$BASE_BRANCH" \ + -B "$HEAD_BRANCH" \ + -R "$HEAD_REPO" \ + --sha "$HEAD_SHA" \ -t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \ -i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}" fi @@ -106,31 +122,22 @@ PLATFORM=$(detect_platform) OWNER=$(get_repo_owner) REPO=$(get_repo_name) -is_known_tea_empty_identity_failure() { - local error_file="$1" - - python3 - "$error_file" <<'PY' -import re -import sys - -with open(sys.argv[1], encoding="utf-8", errors="replace") as handle: - error = handle.read() - -known_empty_identity = re.search( - r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]", - error, - flags=re.IGNORECASE | re.DOTALL, -) -raise SystemExit(0 if known_empty_identity else 1) -PY -} - merge_gitea_with_api() { local host="$1" api_url token basic_auth body_file raw_code payload api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX") - payload='{"Do":"squash"}' + payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY' +import json +import sys + +head_sha, delete_branch = sys.argv[1:] +payload = {"Do": "squash", "head_commit_id": head_sha} +if delete_branch == "true": + payload["delete_branch_after_merge"] = True +print(json.dumps(payload, separators=(",", ":"))) +PY +) token=$(get_gitea_token "$host" || true) if [[ -n "$token" ]]; then @@ -202,7 +209,7 @@ fi case "$PLATFORM" in github) - cmd=(gh pr merge "$PR_NUMBER" --squash) + cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA") [[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch) "${cmd[@]}" ;; @@ -211,32 +218,9 @@ case "$PLATFORM" in echo "Error: Cannot determine host from origin remote URL" >&2 exit 1 } - TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" - - if [[ -n "$TEA_LOGIN" ]]; then - mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" - TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX") - if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then - rm -f "$TEA_ERROR_FILE" - elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then - cat "$TEA_ERROR_FILE" >&2 - echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2 - rm -f "$TEA_ERROR_FILE" - merge_gitea_with_api "$HOST" - else - cat "$TEA_ERROR_FILE" >&2 - rm -f "$TEA_ERROR_FILE" - exit 1 - fi - else - echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2 - merge_gitea_with_api "$HOST" - fi - - # Delete branch after merge if requested - if [[ "$DELETE_BRANCH" == true ]]; then - echo "Note: Branch deletion after merge may need to be done separately with tea" >&2 - fi + # Gitea's API head_commit_id is an atomic compare-and-merge precondition. + # tea cannot express it, so exact-head merges use the authenticated API path. + merge_gitea_with_api "$HOST" ;; *) echo "Error: Could not detect git platform" >&2 diff --git a/packages/mosaic/framework/tools/git/pr-metadata.sh b/packages/mosaic/framework/tools/git/pr-metadata.sh index 55211502..626ae7d2 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -109,7 +109,7 @@ PY detect_platform > /dev/null if [[ "$PLATFORM" == "github" ]]; then - METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft) + METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft) write_metadata "$METADATA" elif [[ "$PLATFORM" == "gitea" ]]; then OWNER=$(get_repo_owner) @@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'): data.get('head_ref'), head_ref, ) +head_sha = first_non_empty( + nested(data, 'head', 'sha'), + nested(data, 'head', 'id'), + data.get('head_sha'), +) +head_repo = first_non_empty( + nested(data, 'head', 'repo', 'full_name'), + nested(data, 'head', 'repo', 'name_with_owner'), +) +if not head_repo: + head_repo_owner = first_non_empty( + nested(data, 'head', 'repo', 'owner', 'login'), + nested(data, 'head', 'repo', 'owner', 'username'), + nested(data, 'head', 'repo', 'owner_name'), + ) + head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name')) + if head_repo_owner and head_repo_name: + head_repo = f'{head_repo_owner}/{head_repo_name}' + base_ref = first_non_empty( nested(data, 'base', 'ref'), nested(data, 'base', 'name'), @@ -207,6 +226,8 @@ normalized = { 'state': data.get('state'), 'author': nested(data, 'user', 'login') or '', 'headRefName': head_ref, + 'headRefOid': head_sha, + 'headRepository': head_repo, 'baseRefName': base_ref, 'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)], 'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)], diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh index f6d7f099..5fdd60f1 100644 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh @@ -13,7 +13,7 @@ # Covers: # (a) 404 branch-absent -> exit 0, "queue clear" message. # (b) 200 existing branch + a terminal CI state -> unchanged behavior. -# (c) genuine API error (500) -> still fail-closed (nonzero exit). +# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0. set -euo pipefail @@ -62,7 +62,7 @@ case "$mode" in 200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;; 500) code=500; body='{"message":"internal server error"}' ;; no-status) code=200; body='{}' ;; - terminal-success) code=200; body='{"state":"success"}' ;; + terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;; *) echo "curl stub: unknown mode=$mode" >&2 exit 2 @@ -91,6 +91,7 @@ run_ci_queue_wait() { export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export GITEA_TOKEN="stub-token" export GITEA_URL="https://git.example.test" + export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl" "$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1 ) } @@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then fail=1 fi -# (c) genuine API error (500) -> still fail-closed, exit nonzero. +# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery. set +e out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1) status_c=$? set -e -if [[ "$status_c" -eq 0 ]]; then - echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2 +if [[ "$status_c" -ne 0 ]]; then + echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2 + echo "$out_c" >&2 + fail=1 +elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then + echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2 echo "$out_c" >&2 fail=1 elif [[ "$out_c" == *"queue clear"* ]]; then echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2 echo "$out_c" >&2 fail=1 +elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then + echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2 + fail=1 fi if [[ "$fail" -eq 0 ]]; then diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh new file mode 100644 index 00000000..d063f12b --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh @@ -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)" diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh new file mode 100644 index 00000000..35ea0aee --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# Exit-asserting RM-03 regression harness for ci-queue-wait.sh. +# Every case is a process-level assertion: a classifier-only green cannot satisfy it. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}" +REPO_DIR="$WORK_DIR/repo" +STUB_DIR="$WORK_DIR/stubs" +AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl" +FEATURE_BRANCH="fix/rm-03-fixture" + +rm -rf "$WORK_DIR" +mkdir -p "$REPO_DIR" "$STUB_DIR" +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH" +git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git + +cat > "$STUB_DIR/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +url="" +has_write_out=0 +for arg in "$@"; do + case "$arg" in + -w) has_write_out=1 ;; + http://*|https://*) url="$arg" ;; + esac +done +printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}" + +case "$url" in + */branches/*) + if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then + exit 7 + fi + body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' + if [[ "$has_write_out" -eq 1 ]]; then + printf '%s\n200' "$body" + else + printf '%s' "$body" + fi + ;; + */status) + case "${MOSAIC_STUB_STATUS_MODE:?}" in + success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;; + pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;; + failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;; + no-status) printf '%s' '{"state":"","statuses":[]}' ;; + aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;; + malformed) printf '%s' 'not-json' ;; + malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;; + malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;; + large-success) + python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")' + ;; + unreachable) exit 7 ;; + *) echo "unknown status mode" >&2; exit 2 ;; + esac + ;; + *) echo "unexpected curl URL: $url" >&2; exit 2 ;; +esac +SH +chmod +x "$STUB_DIR/curl" + +run_guard() { + local status_mode="$1" + local audit_log="${2:-$AUDIT_LOG}" + shift 2 || true + ( + cd "$REPO_DIR" || exit + export PATH="$STUB_DIR:$PATH" + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + if [[ "$status_mode" == "credential-unresolvable" ]]; then + export HOME="$WORK_DIR/empty-home" + mkdir -p "$HOME" + unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY + export MOSAIC_STUB_STATUS_MODE=success + else + export GITEA_TOKEN=stub-token + export GITEA_URL=https://git.example.test + export MOSAIC_STUB_STATUS_MODE="$status_mode" + fi + export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log" + export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log" + "$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@" + ) +} + +failures=0 +run_assertion() { + local name="$1" expected_rc="$2" status_mode="$3" required_text="$4" + local output rc + shift 4 + set +e + output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1) + rc=$? + set -e + + case "$expected_rc" in + zero) + if [[ "$rc" -ne 0 ]]; then + echo "FAIL $name: expected rc=0, got rc=$rc" >&2 + failures=$((failures + 1)) + fi + ;; + nonzero) + if [[ "$rc" -eq 0 ]]; then + echo "FAIL $name: expected rc!=0, got rc=0" >&2 + failures=$((failures + 1)) + fi + ;; + not126) + if [[ "$rc" -eq 126 ]]; then + echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2 + failures=$((failures + 1)) + fi + ;; + esac + if [[ "$output" != *"$required_text"* ]]; then + echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2 + printf '%s\n' "$output" >&2 + failures=$((failures + 1)) + fi +} + +set -e +: > "$WORK_DIR/urls.log" +run_assertion success zero success 'state=terminal-success' +run_assertion pending nonzero pending 'ASSERTED_NOT_READY' +run_assertion failure nonzero failure 'ASSERTED_NOT_READY' +run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY' +run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY' +run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY' +run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY' +run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY' +run_assertion large-payload not126 large-success 'state=terminal-success' +run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT' +run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT' + +if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then + echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2 + failures=$((failures + 1)) +fi + +# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75, +# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record. +merge_audit_lines_before=$(wc -l < "$AUDIT_LOG") +set +e +merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1) +merge_unreachable_rc=$? +set -e +if [[ "$merge_unreachable_rc" -ne 75 ]]; then + echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2 + failures=$((failures + 1)) +fi +if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then + echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2 + failures=$((failures + 1)) +fi +merge_audit_lines_after=$(wc -l < "$AUDIT_LOG") +if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then + echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2 + failures=$((failures + 1)) +fi + +# A feature-branch push with no -B must inspect the checked-out feature branch. +if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then + echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2 + failures=$((failures + 1)) +fi + +# Merge callers can pin both a fork repository and the exact reviewed head SHA. +exact_sha=0123456789abcdef0123456789abcdef01234567 +: > "$WORK_DIR/urls.log" +run_assertion exact-fork-head zero success 'state=terminal-success' \ + -B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha" +if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then + echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2 + failures=$((failures + 1)) +fi +if grep -q '/branches/' "$WORK_DIR/urls.log"; then + echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2 + failures=$((failures + 1)) +fi + +# Platform/repository discovery failures use the same audited CANNOT_ASSERT path. +audit_lines_before=$(wc -l < "$AUDIT_LOG") +git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git +set +e +unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1) +unsupported_rc=$? +set -e +git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git +if [[ "$unsupported_rc" -ne 0 ]]; then + echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2 + failures=$((failures + 1)) +fi +if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then + echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2 + failures=$((failures + 1)) +fi +audit_lines_after=$(wc -l < "$AUDIT_LOG") +if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then + echo "FAIL unsupported-platform: expected an additional audit record" >&2 + failures=$((failures + 1)) +fi + +# A degraded pass is forbidden if the audit receipt cannot be written. +mkdir -p "$WORK_DIR/not-a-directory" +printf 'file' > "$WORK_DIR/not-a-directory/parent" +set +e +audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1) +audit_failure_rc=$? +set -e +if [[ "$audit_failure_rc" -eq 0 ]]; then + echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2 + failures=$((failures + 1)) +fi +if [[ "$audit_failure_output" != *"audit"* ]]; then + echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2 + failures=$((failures + 1)) +fi + +if [[ "$failures" -ne 0 ]]; then + echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2 + exit 1 +fi + +echo "ci-queue-wait tri-state regression passed (all outcome classes)" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index c642b81e..24d4d51e 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback. +# Regression harness for pr-merge.sh Gitea exact-head API path and input safety. set -euo pipefail @@ -79,15 +79,24 @@ emit_response() { printf '200' fi } +if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then + emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}' + exit 0 +fi if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then - emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' + emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' exit 0 fi if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then - if [[ "$post_data" != '{"Do":"squash"}' ]]; then - echo "unexpected merge payload: $post_data" >&2 - exit 96 - fi + POST_DATA="$post_data" python3 - <<'PY' +import json +import os +payload = json.loads(os.environ["POST_DATA"]) +assert payload == { + "Do": "squash", + "head_commit_id": "0123456789abcdef0123456789abcdef01234567", +}, payload +PY emit_response '{"merged":true,"message":"mock merge complete"}' exit 0 fi @@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev" export GITEA_TOKEN="redacted-test-token" OUTPUT="$SANDBOX/output.log" -if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then - echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2 +if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then + echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2 echo "--- output ---" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 echo "--- mock log ---" >&2 @@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then exit 1 fi -cat > "$MOCK_BIN/tea" <<'EOF' -#!/bin/bash -set -euo pipefail -printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" -printf '\n' >> "$PR_MERGE_TEST_LOG" -if [[ "$*" == *"login list"* ]]; then - echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]' - exit 0 -fi -if [[ "$*" == *"pr merge"* ]]; then - echo 'tea network timeout' >&2 - exit 2 -fi -exit 0 -EOF -chmod +x "$MOCK_BIN/tea" -: > "$LOG_FILE" -if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then - echo "Expected arbitrary tea failure to remain blocking." >&2 - exit 1 -fi -if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then - echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2 - sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 - exit 1 -fi -if ! grep -q 'tea network timeout' "$OUTPUT"; then - echo "Expected arbitrary tea error to be preserved in output." >&2 - sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 - exit 1 -fi - cat > "$MOCK_BIN/tea" <<'EOF' #!/bin/bash set -euo pipefail @@ -177,8 +154,8 @@ EOF chmod +x "$MOCK_BIN/tea" unset GITEA_LOGIN : > "$LOG_FILE" -if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then - echo "Expected missing tea login to use authenticated Gitea API fallback." >&2 +if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then + echo "Expected the exact-head API path not to depend on a tea login." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 @@ -215,7 +192,7 @@ cd "$REPO_DIR" git remote set-url origin https://github.com/mosaicstack/stack.git : > "$LOG_FILE" rm -f "$SENTINEL" -if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then +if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then echo "Expected GitHub metacharacter PR number to be rejected." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 exit 1 @@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git export GITEA_LOGIN="git.mosaicstack.dev" : > "$LOG_FILE" rm -f "$SENTINEL" -if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then +if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then echo "Expected Gitea metacharacter PR number to be rejected." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 exit 1 @@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then exit 1 fi -echo "pr-merge.sh Gitea fallback regression passed" +echo "pr-merge.sh Gitea exact-head API regression passed" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh new file mode 100644 index 00000000..0eec10f0 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells. +# The commit whose CI was guarded must be the commit the provider atomically merges. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}" +SHA=0123456789abcdef0123456789abcdef01234567 + +make_fixture() { + local name="$1" remote="$2" + local root="$WORK_DIR/$name" + local tools="$root/tools/git" + mkdir -p "$tools" "$root/repo" + cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh" + cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh" + git -C "$root/repo" init -q + git -C "$root/repo" remote add origin "$remote" + cat > "$tools/pr-metadata.sh" < "$tools/ci-queue-wait.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$tools"/*.sh +} + +rm -rf "$WORK_DIR" +make_fixture gitea https://git.example.test/acme/widgets.git +make_fixture github https://github.com/acme/widgets.git + +cat > "$WORK_DIR/gitea/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +payload="" +for ((i=1; i<=$#; i++)); do + if [[ "${!i}" == "-d" ]]; then + j=$((i + 1)) + payload="${!j}" + fi +done +printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}" +printf '200' +SH +chmod +x "$WORK_DIR/gitea/curl" + +set +e +( + cd "$WORK_DIR/gitea/repo" + export PATH="$WORK_DIR/gitea:$PATH" + export GITEA_TOKEN=stub-token + export GITEA_URL=https://git.example.test + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json" + env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 +) >"$WORK_DIR/gitea.out" 2>&1 +gitea_rc=$? +set -e +if [[ "$gitea_rc" -ne 0 ]]; then + echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2 + cat "$WORK_DIR/gitea.out" >&2 + exit 1 +fi +python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY' +import json +import sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload +assert payload.get("Do") == "squash", payload +assert payload.get("head_commit_id") == sys.argv[2], payload +PY + +# A merge-gate verdict is commit-bound. A stale expected head must fail before merge. +wrong_sha=ffffffffffffffffffffffffffffffffffffffff +rm -f "$WORK_DIR/gitea-payload-stale.json" +set +e +( + cd "$WORK_DIR/gitea/repo" + export PATH="$WORK_DIR/gitea:$PATH" + export GITEA_TOKEN=stub-token + export GITEA_URL=https://git.example.test + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json" + env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha" +) >"$WORK_DIR/gitea-stale.out" 2>&1 +stale_rc=$? +set -e +if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then + echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2 + exit 1 +fi + +# A merge-capable path cannot bypass the mandatory queue guard. The legacy +# --skip-queue-guard option must be rejected before any provider merge call. +cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH' +#!/usr/bin/env bash +exit 99 +SH +chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" +rm -f "$WORK_DIR/gitea-payload-bypass.json" +set +e +( + cd "$WORK_DIR/gitea/repo" + export PATH="$WORK_DIR/gitea:$PATH" + export GITEA_TOKEN=stub-token + export GITEA_URL=https://git.example.test + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json" + env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard +) >"$WORK_DIR/gitea-bypass.out" 2>&1 +bypass_rc=$? +set -e +if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then + echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2 + exit 1 +fi + +# Dry-run is the only path that may omit the guard because it exits before the +# provider merge dispatch. Prove the exit and absence of a merge payload. +rm -f "$WORK_DIR/gitea-payload-dry-run.json" +( + cd "$WORK_DIR/gitea/repo" + export PATH="$WORK_DIR/gitea:$PATH" + export GITEA_TOKEN=stub-token + export GITEA_URL=https://git.example.test + export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json" + env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run +) >"$WORK_DIR/gitea-dry-run.out" 2>&1 +if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then + echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2 + exit 1 +fi + +cat > "$WORK_DIR/github/gh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}" +SH +chmod +x "$WORK_DIR/github/gh" +( + cd "$WORK_DIR/github/repo" + export PATH="$WORK_DIR/github:$PATH" + export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log" + "$WORK_DIR/github/tools/git/pr-merge.sh" -n 123 +) >"$WORK_DIR/github.out" 2>&1 +if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then + echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2 + cat "$WORK_DIR/github-call.log" >&2 + exit 1 +fi + +echo "PR merge exact-head pin regression passed (Gitea + GitHub)" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh new file mode 100644 index 00000000..00ed3b4e --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh @@ -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" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 401d0015..5aee1571 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",