Compare commits
4
Commits
next
...
f4b162fa5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b162fa5c | ||
|
|
abe9dbb5c1 | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 |
@@ -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,83 @@
|
||||
# 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 single-author API payload remains exactly `{ "Do": "squash" }`.
|
||||
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.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Version the currently deployed wrapper byte-for-byte under `infra/fleet/tools/git/pr-merge.sh`.
|
||||
2. Pre-register verified, null-login BLOCK, unchanged single-author, and hardcoded-squash tests; observe RED before implementation.
|
||||
3. Implement authenticated commit enumeration, construction-only trailers, message fields on REST, and force REST when trailers are requested.
|
||||
4. Copy the exact final versioned bytes to the deployed wrapper; verify hashes match.
|
||||
5. Run focused and baseline checks, static/security review, identity-bound commit, queue guard plus direct Woodpecker terminal enumeration, push, self-post PR, and provider poster read-back. Stop at push/PR; do not 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 after implementation: verified linked multi-author payload, null-login BLOCK, required named principal, unchanged ordinary `{ "Do": "squash" }` payload, hard non-squash refusal, 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`.
|
||||
- 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 both fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, unchanged ordinary payload, and retained log-safe provider diagnostics.
|
||||
4. Codex review rounds 3–5 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 still required
|
||||
|
||||
- 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 and classifies the landed squash object as `APPENDS`, `OVERWRITES`, or `REPLACES`; its absence control must also be demonstrated.
|
||||
- Arm B includes a registered foreign branch trailer and classifies it as `SURVIVES` or `DROPPED`; verify that identity through an existing commit whose `author.login` resolves. 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`. Stop on `OVERWRITES`, `REPLACES`, or 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.
|
||||
|
||||
## Current hold point
|
||||
|
||||
No delivery branch push or PR exists. The deployed wrapper remains on its original baseline bytes. Implementation and disposable end-to-end read-back are green; final review disposition, commit/rebase, and delivery gates remain.
|
||||
Executable
+623
@@ -0,0 +1,623 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Default values
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
CO_AUTHOR_TRAILERS=false
|
||||
ESCALATE_TO=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Merge a pull request on the current repository (Gitea or GitHub).
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--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
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--method)
|
||||
MERGE_METHOD="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--delete-branch)
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--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)
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
echo "Error: PR number is required (-n)" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: Invalid PR number '$PR_NUMBER'. PR number must contain digits only." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&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")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").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
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$BASE_BRANCH" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
write_curl_auth_config() {
|
||||
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"
|
||||
}
|
||||
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
MERGE_TEMP_DIRS=()
|
||||
|
||||
format_gitea_error_response() {
|
||||
local response_file="$1"
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "rb") as handle:
|
||||
raw = handle.read(65536)
|
||||
try:
|
||||
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
message = "non-JSON response omitted"
|
||||
else:
|
||||
if isinstance(response, dict):
|
||||
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
|
||||
}
|
||||
|
||||
cleanup_merge_temp_dirs() {
|
||||
local path
|
||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||
done
|
||||
}
|
||||
trap cleanup_merge_temp_dirs EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
fetch_gitea_pr_head() {
|
||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||
local response_file raw_code api_url
|
||||
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||
raw_code=$(write_curl_auth_config "$auth_mode" "$credential" | \
|
||||
curl -sS -K - -w '%{http_code}' -o "$response_file" \
|
||||
-H "User-Agent: curl/8" "$api_url" || true)
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
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
|
||||
|
||||
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
|
||||
mkdir -p "$work_root"
|
||||
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=$(write_curl_auth_config "$auth_mode" "$credential" | \
|
||||
curl -sS -K - -w '%{http_code}' -o "$page_file" \
|
||||
-H "User-Agent: curl/8" "$api_url" || true)
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
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 sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
page = json.load(handle)
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
); 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 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(json.dumps({"head_commit_id": head_sha}, separators=(",", ":")))
|
||||
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({
|
||||
"head_commit_id": head_sha,
|
||||
"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
|
||||
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 "$work_root/pr-merge-commits-input.XXXXXX")
|
||||
context_file=$(mktemp "$work_root/pr-merge-message-context.XXXXXX")
|
||||
head_file=$(mktemp "$work_root/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 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" > "$payload_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
fields = json.load(handle)
|
||||
payload = {"Do": "squash"}
|
||||
payload.update(fields)
|
||||
if payload.get("Do") != "squash" or set(payload) - {"Do", "head_commit_id", "MergeTitleField", "MergeMessageField"}:
|
||||
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"
|
||||
|
||||
raw_code=$(write_curl_auth_config "$auth_mode" "$credential" | \
|
||||
curl -sS -K - -w '%{http_code}' -o "$body_file" \
|
||||
-X POST -H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "@$payload_file" "$api_url" || true)
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
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 basic_auth attempt_rc
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
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 token credential (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR} Basic Auth fallback is allowed only after HTTP 401." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Token credential received HTTP 401; retrying inspection and merge with configured Basic Auth." >&2
|
||||
fi
|
||||
|
||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||
if [[ -n "$basic_auth" ]]; then
|
||||
if merge_gitea_api_attempt "$host" basic "$basic_auth"; then
|
||||
return 0
|
||||
else
|
||||
attempt_rc=$?
|
||||
fi
|
||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||
return 75
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$token" && -z "$basic_auth" ]]; then
|
||||
echo "Error: No Gitea credential is available for the merge operation." >&2
|
||||
else
|
||||
echo "Error: Gitea API merge failed for all configured credentials (last HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
if [[ "$PLATFORM" == "gitea" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; 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)."
|
||||
elif [[ -n "$TEA_LOGIN" ]]; then
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
||||
fi
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
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)
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
gitea)
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
merge_gitea_with_api "$HOST"
|
||||
elif [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "PR #$PR_NUMBER merged successfully"
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
#!/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:?}"
|
||||
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 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
|
||||
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main"}\n' "$title"
|
||||
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"
|
||||
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
|
||||
;;
|
||||
-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:?}"
|
||||
|
||||
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
|
||||
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)
|
||||
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"}}]'
|
||||
;;
|
||||
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"
|
||||
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_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
|
||||
|
||||
# Token rejection during inspection must fall back to Basic Auth, then repeat
|
||||
# BOTH inspection and merge with that one Basic credential handle.
|
||||
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" -eq 0 ]] || fail "inspection fallback expected rc=0, got rc=$fallback_inspect_rc: $fallback_inspect_output"
|
||||
[[ -s "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection fallback did not reach the merge API"
|
||||
[[ -e "$fallback_inspect_dir/basic-via-config" ]] || fail "inspection fallback did not transport Basic Auth through stdin config"
|
||||
[[ ! -e "$fallback_inspect_dir/basic-in-argv" ]] || fail "inspection fallback exposed Basic Auth in curl argv"
|
||||
[[ ! -e "$fallback_inspect_dir/metadata-in-argv" ]] || fail "inspection fallback exposed PR metadata in child argv"
|
||||
[[ "$(wc -l < "$fallback_inspect_dir/token-resolution.log")" -eq 1 ]] || fail "inspection fallback did not resolve token exactly once"
|
||||
[[ "$(wc -l < "$fallback_inspect_dir/basic-resolution.log")" -eq 1 ]] || fail "inspection fallback did not resolve Basic Auth exactly once"
|
||||
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||
[[ "$inspect_sequence" == "GET:token,GET:basic,GET:basic,POST:basic" ]] || fail "inspection fallback was not bound per credential (calls=$inspect_sequence)"
|
||||
|
||||
# Token rejection at merge is a separate seam: Basic fallback must re-inspect
|
||||
# instead of reusing evidence gathered under the rejected token.
|
||||
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" -eq 0 ]] || fail "merge fallback expected rc=0, got rc=$fallback_merge_rc: $fallback_merge_output"
|
||||
[[ -s "$fallback_merge_dir/merge-payload.json" ]] || fail "merge fallback did not reach a successful merge API payload"
|
||||
[[ -e "$fallback_merge_dir/basic-via-config" ]] || fail "merge fallback did not transport Basic Auth through stdin config"
|
||||
[[ ! -e "$fallback_merge_dir/basic-in-argv" ]] || fail "merge fallback exposed Basic Auth in curl argv"
|
||||
[[ ! -e "$fallback_merge_dir/metadata-in-argv" ]] || fail "merge fallback exposed PR metadata in child argv"
|
||||
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||
[[ "$merge_sequence" == "GET:token,GET:token,POST:token,GET:basic,GET:basic,POST:basic" ]] || fail "merge fallback reused cross-credential evidence (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"
|
||||
[[ ! -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"
|
||||
[[ ! -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"}, 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)"
|
||||
@@ -9,10 +9,51 @@ 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
|
||||
@@ -33,6 +74,9 @@ 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
|
||||
@@ -44,6 +88,7 @@ case "$url" in
|
||||
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"}]}' ;;
|
||||
@@ -63,7 +108,31 @@ case "$url" in
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
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"
|
||||
@@ -83,13 +152,46 @@ run_guard() {
|
||||
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"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||
# 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
|
||||
@@ -124,6 +226,13 @@ run_assertion() {
|
||||
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
|
||||
@@ -140,6 +249,27 @@ 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))
|
||||
@@ -160,6 +290,7 @@ 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
|
||||
@@ -223,6 +354,7 @@ 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
|
||||
|
||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
||||
# 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.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
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) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# 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
|
||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||
# the trap and shows the buggy resume-to-success.
|
||||
make_term_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
# success. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
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() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
make_term_shim "$SHIM"
|
||||
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
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
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)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
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" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||
"$INSTALL" > "$NOEXIT"
|
||||
chk "[control] the exit-strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"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).
|
||||
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
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
|
||||
Reference in New Issue
Block a user