From caf19e89804c3f7dba6d521cbd3a7a2654e6b9ca Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 17:55:27 -0700 Subject: [PATCH] feat(ops): hourly alarm for auto-promote PR stuck on REVIEW_REQUIRED (#2975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the silent-block failure mode that left 25 commits — including the Memory v2 redesign and the reno-stars data-loss fix — wedged on staging for 12+ hours behind a single missing review. The auto-promote workflow opened the PR + armed auto-merge, but main's branch protection required a human review and nobody noticed until a user reported "still seeing old memory tab". ## Detection logic — `scripts/check-stale-promote-pr.sh` Reads open PRs `base=main head=staging` and alarms on: - `mergeStateStatus == BLOCKED` - `reviewDecision == REVIEW_REQUIRED` - createdAt older than `STALE_HOURS` (default 4h) Other BLOCKED reasons (DIRTY, BEHIND, failed checks) are NOT alarmed — those are the author's signal-to-fix. This script targets the specific "no human reviewed yet" wedge. Output: - `::warning` per stale PR (visible in workflow summary + Actions UI) - PR comment (idempotent via marker-string detection; one alarm per PR, never re-spammed) - Exit code = count of stale PRs (capped at 125) Logic in a script (not inline workflow YAML) so it's: - **Unit-testable** — tests/test-check-stale-promote-pr.sh exercises every branch with stubbed fixture JSON + frozen clock. 23 tests covering: empty list, single stale, just-under-threshold, wrong reviewDecision, wrong mergeStateStatus, mixed list (only matching PRs alarm), custom threshold via --stale-hours, exit-code-counts- matching-PRs, --help, unknown arg → 64, missing repo → 2. - **Operator-runnable ad-hoc** — `scripts/check-stale-promote-pr.sh` works from any shell with `gh` + `jq`. - **SSOT** — one detector, the workflow YAML is just schedule + invocation surface. Future sibling workflows that need the same check call the same script. ## Workflow — `.github/workflows/auto-promote-stale-alarm.yml` Triggers: - cron `27 * * * *` (hourly, off-the-hour to dodge cron herd) - workflow_dispatch with `stale_hours` + `post_comment` overrides Concurrency: `auto-promote-stale-alarm` group, cancel-in-progress=false (idempotent script; no benefit to cancelling a running scan). Permissions: `contents: read` + `pull-requests: write` (post comments). Sparse checkout — only fetches `scripts/check-stale-promote-pr.sh`. No node_modules, no go modules, no slow setup steps. Workflow runs in <30s on a clean repo. ## Why "alarm + comment" not "auto-approve" Considered options in issue #2975: 1. Slack/email alert — picked. 2. Bot-account auto-approve via molecule-ops — circumvents the human-review gate that branch protection encodes. 3. Trusted-promote bypass via CODEOWNERS — needs Org Admin config change; out of scope for a workflow PR. The comment-on-PR pattern picks (1) without external dependencies (no Slack token, no email config). Subscribers get notified via GitHub's existing PR notification delivery; the warning shows up in the Actions feed. ## Why this won't false-positive on legitimate slow reviews Threshold is 4h. Most legitimate gates clear in <1h, so 4× headroom is plenty for slow CI. The comment is idempotent (one alarm per PR, never re-posted) — adding noise stops at 1 comment regardless of how long the PR sits. ## Test plan - [x] `bash scripts/test-check-stale-promote-pr.sh` — 23/23 pass - [x] `python3 -c 'yaml.safe_load(...)'` clean - [x] `bash -n` clean on both scripts - [ ] Live verification: dispatch the workflow once main has caught up, confirm it correctly reports zero stale PRs --- .../workflows/auto-promote-stale-alarm.yml | 83 ++++++ scripts/check-stale-promote-pr.sh | 216 +++++++++++++++ scripts/test-check-stale-promote-pr.sh | 257 ++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 .github/workflows/auto-promote-stale-alarm.yml create mode 100755 scripts/check-stale-promote-pr.sh create mode 100755 scripts/test-check-stale-promote-pr.sh diff --git a/.github/workflows/auto-promote-stale-alarm.yml b/.github/workflows/auto-promote-stale-alarm.yml new file mode 100644 index 00000000..58667c6f --- /dev/null +++ b/.github/workflows/auto-promote-stale-alarm.yml @@ -0,0 +1,83 @@ +name: auto-promote-stale-alarm + +# Hourly cron + on-demand alarm for the silent-block failure mode that +# motivated issue #2975: +# - The auto-promote-staging.yml workflow opened a PR + armed +# auto-merge, but main's branch protection requires a human review +# (reviewDecision=REVIEW_REQUIRED). The PR sat BLOCKED with no +# surface-up-the-stack for 12+ hours, holding 25 commits hostage +# including the Memory v2 redesign and a reno-stars data-loss fix. +# +# This workflow runs `scripts/check-stale-promote-pr.sh` against the +# repo's open auto-promote PRs (base=main head=staging). When a PR has +# been BLOCKED on REVIEW_REQUIRED for >4h, it: +# 1. Emits a workflow-level warning (visible in run summary + the +# Actions UI feed). +# 2. Posts a comment on the PR (idempotent — one alarm per PR). +# +# The detection logic lives in scripts/check-stale-promote-pr.sh so +# it's unit-testable with stubbed `gh` (see test-check-stale-promote-pr.sh). +# This file is the schedule + invocation surface only — SSOT for the +# detector itself. + +on: + schedule: + # Hourly. Cheap (one `gh pr list` + jq), and 1h granularity is + # plenty for a 4h staleness threshold — operators see the alarm + # within at most 1h of crossing the threshold. + - cron: "27 * * * *" # at :27 to dodge the cron herd at :00 + workflow_dispatch: + inputs: + stale_hours: + description: "Hours after which a BLOCKED+REVIEW_REQUIRED PR is stale (default 4)" + required: false + default: "4" + post_comment: + description: "Post a comment on stale PRs (default true)" + required: false + default: "true" + +permissions: + contents: read + pull-requests: write # post comments on stale PRs + +# Serialize so the on-demand and scheduled runs don't double-comment +# the same PR. cancel-in-progress=false because the script is idempotent +# (existing comment marker prevents dupes), but a scheduled run firing +# while a manual one runs would just re-list the same PR set. +concurrency: + group: auto-promote-stale-alarm + cancel-in-progress: false + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - name: Checkout (need scripts/ only) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + scripts/check-stale-promote-pr.sh + sparse-checkout-cone-mode: false + - name: Run stale-PR detector + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + STALE_HOURS: ${{ inputs.stale_hours || '4' }} + POST_COMMENT: ${{ inputs.post_comment || 'true' }} + run: | + # The script's exit code reflects the count of stale PRs. + # We don't want a stale finding to fail the workflow run — + # the warning + comment are the signal, the green/red is + # noise. So convert any non-zero exit to a workflow notice + # and exit 0. + set +e + bash scripts/check-stale-promote-pr.sh + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + echo "::notice::Stale PR detector found $rc PR(s) needing attention. See warnings above + comments on the PRs." + fi + # Always succeed — operator-facing surface is the warning, + # not the workflow status. + exit 0 diff --git a/scripts/check-stale-promote-pr.sh b/scripts/check-stale-promote-pr.sh new file mode 100755 index 00000000..bcc5afe6 --- /dev/null +++ b/scripts/check-stale-promote-pr.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# scripts/check-stale-promote-pr.sh +# +# Scan open auto-promote PRs (base=main head=staging) for the +# silent-block failure mode that motivated issue #2975: +# - PR sat for hours with mergeStateStatus=BLOCKED +# - reviewDecision=REVIEW_REQUIRED (auto-merge armed but waiting +# on a human approval that never comes) +# +# When found, emit: +# - GitHub Actions notice/warning lines (workflow summary surface) +# - Optionally post a comment on the PR (--comment) +# +# Exit code is the count of stale PRs found, capped at 125 so callers +# can detect "alarm fired" via `if ! check-stale-promote-pr.sh; then …`. +# Exit 0 = clean, exit ≥1 = at least N stale PRs need attention. +# +# Used by .github/workflows/auto-promote-stale-alarm.yml. Logic lives +# here (not inline in the workflow YAML) so we can: +# - Unit-test it with a stubbed `gh` (see test-check-stale-promote-pr.sh) +# - Run it ad-hoc by an operator: `scripts/check-stale-promote-pr.sh` +# - Reuse the same surface in any sibling workflow that needs the same +# check (SSOT — one detector, many callers). +# +# Requires: `gh` CLI, `jq`. `GH_TOKEN` env in the workflow context. + +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Inputs +# ----------------------------------------------------------------------------- + +# Threshold beyond which a BLOCKED+REVIEW_REQUIRED promote PR is "stale" +# enough to alarm. 4 hours is the floor: most legitimate gates clear +# inside an hour, so 4× headroom is plenty for slow CI without false- +# alarming. Override via env for tests + edge ops. +STALE_HOURS="${STALE_HOURS:-4}" + +# Repo defaults to the current `gh` context. Tests pass --repo explicitly. +REPO="${GITHUB_REPOSITORY:-}" + +# Whether to post a comment to the PR. Off by default to avoid noise on +# manual ad-hoc runs; the cron workflow turns it on. +POST_COMMENT="${POST_COMMENT:-false}" + +# Where to read the open-PR JSON from. Empty = call `gh` live. Tests +# point this at a fixture file. +PR_FIXTURE="${PR_FIXTURE:-}" + +# Where to read "now" from. Empty = real clock. Tests freeze time so +# the staleness math is deterministic. +NOW_OVERRIDE="${NOW_OVERRIDE:-}" + +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="$2"; shift 2 ;; + --comment) POST_COMMENT="true"; shift ;; + --no-comment) POST_COMMENT="false"; shift ;; + --fixture) PR_FIXTURE="$2"; shift 2 ;; + --stale-hours) STALE_HOURS="$2"; shift 2 ;; + -h|--help) + sed -n '1,/^set /p' "$0" | grep '^# ' | sed 's/^# //' + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 64 ;; + esac +done + +if [ -z "$REPO" ] && [ -z "$PR_FIXTURE" ]; then + echo "::error::REPO env (or GITHUB_REPOSITORY) required when no fixture given" >&2 + exit 2 +fi + +# ----------------------------------------------------------------------------- +# Clock helpers — split out so tests can freeze time +# ----------------------------------------------------------------------------- + +now_epoch() { + if [ -n "$NOW_OVERRIDE" ]; then + printf '%s\n' "$NOW_OVERRIDE" + else + date -u +%s + fi +} + +# Parse RFC3339 timestamps the way GitHub emits them (e.g. +# "2026-05-05T23:15:00Z"). gnu-date uses -d, bsd-date uses -j -f. Cover +# both because the workflow runs on ubuntu-latest (gnu) but operators +# may run this script on macOS (bsd). +to_epoch() { + local ts="$1" + # gnu-date path first. + if date -u -d "$ts" +%s 2>/dev/null; then + return 0 + fi + # bsd-date fallback — strip optional fractional seconds before %S. + local ts_clean="${ts%%.*}" + ts_clean="${ts_clean%Z}Z" + date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts_clean" +%s 2>/dev/null || { + echo "::error::cannot parse timestamp: $ts" >&2 + return 1 + } +} + +# ----------------------------------------------------------------------------- +# Fetch open auto-promote PRs +# ----------------------------------------------------------------------------- + +fetch_prs() { + if [ -n "$PR_FIXTURE" ]; then + cat "$PR_FIXTURE" + return 0 + fi + gh pr list --repo "$REPO" \ + --base main --head staging --state open \ + --json number,title,createdAt,mergeStateStatus,reviewDecision,url +} + +# ----------------------------------------------------------------------------- +# Stale detection +# ----------------------------------------------------------------------------- + +# Read PR list from stdin, emit one TSV line per stale PR: +# \t\t\t +# Caller decides what to do (warn, comment, escalate). +detect_stale() { + local now_ts + now_ts="$(now_epoch)" + local stale_seconds=$((STALE_HOURS * 3600)) + + jq -r '.[] | [.number, .createdAt, .mergeStateStatus, .reviewDecision, .url, .title] | @tsv' \ + | while IFS=$'\t' read -r num created_at merge_state review_decision url title; do + # Only alarm on the specific failure mode: BLOCKED + REVIEW_REQUIRED. + # Other BLOCKED reasons (DIRTY, BEHIND, failed checks) are the + # author's signal-to-fix; this script targets the silent + # "no human reviewed yet" wedge specifically. + [ "$merge_state" = "BLOCKED" ] || continue + [ "$review_decision" = "REVIEW_REQUIRED" ] || continue + + local created_ts + created_ts="$(to_epoch "$created_at")" || continue + local age=$((now_ts - created_ts)) + if [ "$age" -ge "$stale_seconds" ]; then + local age_h=$((age / 3600)) + printf '%s\t%d\t%s\t%s\n' "$num" "$age_h" "$url" "$title" + fi + done +} + +# ----------------------------------------------------------------------------- +# Reporting +# ----------------------------------------------------------------------------- + +# Comment body — kept short; the issue body has the full design. +comment_body() { + local age_h="$1" + cat <<EOF +⚠️ This auto-promote PR has been BLOCKED on \`REVIEW_REQUIRED\` for **${age_h}h**. + +Auto-merge is armed, but main's branch protection requires 1 review and no human has approved. Until someone reviews, the staging→main promote chain is wedged and downstream consumers (canvas builds, tenant redeploys) won't see new code. + +**Action**: a human reviewer on \`@Molecule-AI/maintainers\` should approve this PR (or mark it as not ready and close). + +Detected by \`scripts/check-stale-promote-pr.sh\` per issue #2975. +EOF +} + +post_comment() { + local pr_num="$1" + local age_h="$2" + if [ "$POST_COMMENT" != "true" ]; then + return 0 + fi + # Idempotency: only one alarm comment per PR. Look for the marker + # string in existing comments before posting a new one. + local existing + existing="$(gh pr view "$pr_num" --repo "$REPO" --json comments \ + --jq '.comments[] | select(.body | test("scripts/check-stale-promote-pr.sh per issue #2975")) | .databaseId' \ + | head -n1)" + if [ -n "$existing" ]; then + echo "::notice::PR #$pr_num already has a stale-alarm comment ($existing) — not re-posting" + return 0 + fi + comment_body "$age_h" | gh pr comment "$pr_num" --repo "$REPO" --body-file - + echo "::notice::Posted stale-alarm comment on PR #$pr_num (age=${age_h}h)" +} + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- + +stale_count=0 +while IFS=$'\t' read -r num age_h url title; do + [ -n "$num" ] || continue + stale_count=$((stale_count + 1)) + echo "::warning title=Stale auto-promote PR::PR #$num — BLOCKED on REVIEW_REQUIRED for ${age_h}h. $url" + { + echo "## ⚠️ Stale auto-promote PR detected" + echo + echo "- PR: #$num — \`$title\`" + echo "- Age: ${age_h}h" + echo "- State: BLOCKED on REVIEW_REQUIRED" + echo "- URL: $url" + echo + echo "Auto-merge is armed but waiting on a human review. See issue #2975." + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + post_comment "$num" "$age_h" +done < <(fetch_prs | detect_stale) + +if [ "$stale_count" -eq 0 ]; then + echo "::notice::No stale auto-promote PRs detected (threshold: ${STALE_HOURS}h)" +fi + +# Cap exit code so we don't accidentally break shells that interpret +# >125 as signal-style. 1..N maps to "1..N stale PRs". +exit $(( stale_count > 125 ? 125 : stale_count )) diff --git a/scripts/test-check-stale-promote-pr.sh b/scripts/test-check-stale-promote-pr.sh new file mode 100755 index 00000000..3b8caba9 --- /dev/null +++ b/scripts/test-check-stale-promote-pr.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# scripts/test-check-stale-promote-pr.sh +# +# Exhaustive bash unit tests for check-stale-promote-pr.sh. +# Goal: 100% branch coverage on the detector logic. +# +# Each case writes a fixture JSON, freezes the clock with NOW_OVERRIDE, +# runs the script with --fixture + --no-comment (so we don't try to +# actually call `gh pr comment`), and asserts on stdout/exit code. +# +# Run: bash scripts/test-check-stale-promote-pr.sh +# Expected: "All N tests passed" + exit 0. + +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/check-stale-promote-pr.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +# Frozen "now" — 2026-05-06T05:00:00Z. Compute dynamically so the +# tests stay correct regardless of platform-specific date semantics +# (gnu vs bsd) and any author math errors on the epoch. +if FROZEN_NOW="$(date -u -d '2026-05-06T05:00:00Z' +%s 2>/dev/null)"; then + : # gnu-date worked +elif FROZEN_NOW="$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2026-05-06T05:00:00Z' +%s 2>/dev/null)"; then + : # bsd-date worked +else + echo "FATAL: cannot compute FROZEN_NOW on this platform" >&2 + exit 1 +fi + +run_script() { + # Args: <fixture-file> + # Returns stdout + exit code via a known marker. + local fixture="$1" + shift + set +e + NOW_OVERRIDE="$FROZEN_NOW" \ + POST_COMMENT="false" \ + bash "$SCRIPT" --fixture "$fixture" "$@" 2>&1 + local rc=$? + set -e + echo "EXIT_CODE=$rc" +} + +assert_pass() { + local name="$1" + local got="$2" + local want_pattern="$3" + if printf '%s' "$got" | grep -qE "$want_pattern"; then + PASS=$((PASS + 1)) + printf ' ✓ %s\n' "$name" + else + FAIL=$((FAIL + 1)) + printf ' ✗ %s\n want pattern: %s\n got:\n%s\n' "$name" "$want_pattern" "$got" + fi +} + +assert_no_match() { + local name="$1" + local got="$2" + local bad_pattern="$3" + if printf '%s' "$got" | grep -qE "$bad_pattern"; then + FAIL=$((FAIL + 1)) + printf ' ✗ %s\n bad pattern matched: %s\n got:\n%s\n' "$name" "$bad_pattern" "$got" + else + PASS=$((PASS + 1)) + printf ' ✓ %s\n' "$name" + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# Test cases +# ───────────────────────────────────────────────────────────────────────────── + +echo "1. Empty PR list — clean exit" +echo '[]' > "$TMP/empty.json" +got=$(run_script "$TMP/empty.json") +assert_pass "empty-no-warning" "$got" "No stale auto-promote PRs detected" +assert_pass "empty-exit-zero" "$got" "EXIT_CODE=0" + +echo +echo "2. Single PR, BLOCKED+REVIEW_REQUIRED, 5h old — fires alarm" +cat > "$TMP/stale1.json" <<EOF +[{ + "number": 2963, + "title": "staging → main", + "createdAt": "2026-05-06T00:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "url": "https://github.com/test/test/pull/2963" +}] +EOF +got=$(run_script "$TMP/stale1.json") +assert_pass "stale1-warning" "$got" "Stale auto-promote PR" +assert_pass "stale1-pr-number" "$got" "PR #2963" +assert_pass "stale1-age" "$got" "for 5h" +assert_pass "stale1-exit-1" "$got" "EXIT_CODE=1" + +echo +echo "3. Same PR but only 3h old — under threshold, NO alarm" +cat > "$TMP/young.json" <<EOF +[{ + "number": 100, + "title": "fresh promote", + "createdAt": "2026-05-06T02:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "url": "https://github.com/test/test/pull/100" +}] +EOF +got=$(run_script "$TMP/young.json") +assert_pass "young-no-alarm" "$got" "No stale auto-promote PRs" +assert_pass "young-exit-zero" "$got" "EXIT_CODE=0" +assert_no_match "young-no-warning" "$got" "Stale auto-promote PR" + +echo +echo "4. PR is BLOCKED but for the wrong reason (DIRTY, not REVIEW_REQUIRED)" +cat > "$TMP/dirty.json" <<EOF +[{ + "number": 200, + "title": "needs rebase", + "createdAt": "2026-05-06T00:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "APPROVED", + "url": "https://github.com/test/test/pull/200" +}] +EOF +got=$(run_script "$TMP/dirty.json") +assert_pass "dirty-no-alarm" "$got" "No stale auto-promote PRs" +assert_pass "dirty-exit-zero" "$got" "EXIT_CODE=0" + +echo +echo "5. PR is APPROVED but mergeStateStatus is CLEAN — NOT alarming" +cat > "$TMP/clean.json" <<EOF +[{ + "number": 300, + "title": "all green", + "createdAt": "2026-05-06T00:00:00Z", + "mergeStateStatus": "CLEAN", + "reviewDecision": "APPROVED", + "url": "https://github.com/test/test/pull/300" +}] +EOF +got=$(run_script "$TMP/clean.json") +assert_pass "clean-no-alarm" "$got" "No stale auto-promote PRs" + +echo +echo "6. Multiple PRs — only the BLOCKED+REVIEW_REQUIRED+old one alarms" +cat > "$TMP/mixed.json" <<EOF +[ + { + "number": 100, + "title": "fresh", + "createdAt": "2026-05-06T04:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "url": "https://x/100" + }, + { + "number": 200, + "title": "stale + alarming", + "createdAt": "2026-05-05T20:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "url": "https://x/200" + }, + { + "number": 300, + "title": "approved + clean", + "createdAt": "2026-05-05T20:00:00Z", + "mergeStateStatus": "CLEAN", + "reviewDecision": "APPROVED", + "url": "https://x/300" + } +] +EOF +got=$(run_script "$TMP/mixed.json") +assert_pass "mixed-only-200" "$got" "PR #200" +assert_no_match "mixed-not-100" "$got" "PR #100" +assert_no_match "mixed-not-300" "$got" "PR #300" +assert_pass "mixed-exit-1" "$got" "EXIT_CODE=1" + +echo +echo "7. Custom STALE_HOURS via --stale-hours overrides threshold" +got=$(run_script "$TMP/young.json" --stale-hours 1) +assert_pass "custom-threshold-fires" "$got" "PR #100" +assert_pass "custom-threshold-exit-1" "$got" "EXIT_CODE=1" + +echo +echo "8. Two stale PRs — exit code reflects count" +cat > "$TMP/two-stale.json" <<EOF +[ + { + "number": 200, + "title": "stale-A", + "createdAt": "2026-05-05T20:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "url": "https://x/200" + }, + { + "number": 201, + "title": "stale-B", + "createdAt": "2026-05-05T19:00:00Z", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "url": "https://x/201" + } +] +EOF +got=$(run_script "$TMP/two-stale.json") +assert_pass "two-stale-exit-2" "$got" "EXIT_CODE=2" + +echo +echo "9. Help text is shown for --help" +set +e +help_out=$(bash "$SCRIPT" --help 2>&1) +help_rc=$? +set -e +assert_pass "help-exits-zero" "EXIT_CODE=$help_rc" "EXIT_CODE=0" +assert_pass "help-mentions-issue" "$help_out" "issue #2975" + +echo +echo "10. Unknown arg exits 64 (EX_USAGE)" +set +e +bad_out=$(bash "$SCRIPT" --bogus 2>&1) +bad_rc=$? +set -e +assert_pass "unknown-arg-rc" "EXIT_CODE=$bad_rc" "EXIT_CODE=64" + +echo +echo "11. Missing repo + missing fixture exits 2" +set +e +out=$(REPO="" bash "$SCRIPT" 2>&1) +rc=$? +set -e +assert_pass "no-repo-exit-2" "EXIT_CODE=$rc" "EXIT_CODE=2" + +# ───────────────────────────────────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────────────────────────────────── + +echo +echo "─────────────────────────────────────────────" +echo "Tests: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +echo "All tests passed."