diff --git a/.standards-check-ignore b/.standards-check-ignore new file mode 100644 index 0000000..fbe334d --- /dev/null +++ b/.standards-check-ignore @@ -0,0 +1,3 @@ +# Repos that never get standards-check.yml installed by +# bulk-install-standards-check.sh. One owner/repo per line; # is a comment. +nightowlstudiollc/networth-agent diff --git a/README.md b/README.md index 653843b..2ad33d6 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,9 @@ jobs: uses: smartwatermelon/github-workflows/.github/workflows/standards-check.yml@standards-check-v1 ``` +The canonical copy is `standards/caller-stub.yml`; +`bulk-install-standards-check.sh` installs it fleet-wide. + Name the caller job `standards-check`; the required check is then `standards-check / run-standards-check` (caller job `standards-check`, inner job `run-standards-check`). With reusable workflows, GitHub reports the diff --git a/bulk-install-standards-check.sh b/bulk-install-standards-check.sh new file mode 100755 index 0000000..a0948ff --- /dev/null +++ b/bulk-install-standards-check.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# Install the standards-check.yml caller stub across the fleet. +# +# Classifies every non-archived repo of --owners (plus --extra-repos) as +# MISSING / CURRENT / DIFFERS / IGNORED / ARCHIVED / ERROR and, with --apply, +# writes the canonical stub (standards/caller-stub.yml) into MISSING repos. +# +# --mode=pr branch chore/standards-check-stub + Contents-API put + PR +# --mode=push Contents-API put straight onto the default branch. This is +# the W2 Phase-2 mechanism Andrew authorized on 2026-09-08 for +# this one file only (dev-env docs/superpowers/plans/ +# 2026-09-08-w2-fleet-rollout.md, "Decisions"). Never extend +# it to other content. +# +# Dry run is the default. DIFFERS repos are reported and never touched. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GH="${BULK_GH:-gh}" +STUB_FILE="${BULK_STUB_FILE:-${here}/standards/caller-stub.yml}" +IGNORE_FILE="${BULK_IGNORE_FILE:-${here}/.standards-check-ignore}" +INSTALL_PATH=".github/workflows/standards-check.yml" +BRANCH="chore/standards-check-stub" +SESSION="https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf" + +APPLY=false +MODE="pr" +ONLY="" +OWNERS="smartwatermelon,nightowlstudiollc" +EXTRA="twistedmelonman/dotfiles,twistedmelonman/claude-config,twistedmelonman/personify,twistedmelonman/huddle-transcribe,twistedmelonman/projectinsomnia" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +[[ -f "${STUB_FILE}" ]] || { + echo "stub file not found: ${STUB_FILE}" >&2 + exit 2 +} +STUB_B64="$(base64 <"${STUB_FILE}" | tr -d '\n')" + +is_ignored() { + [[ -f "${IGNORE_FILE}" ]] || return 1 + grep -v '^[[:space:]]*#' "${IGNORE_FILE}" | grep -qx "$1" +} + +# Emits "owner/repoarchived(true|false)" lines. +list_repos() { + local owner r + local -a owners extras + IFS=',' read -r -a owners <<<"${OWNERS}" + for owner in "${owners[@]}"; do + [[ -n "${owner}" ]] || continue + "${GH}" repo list "${owner}" --json name,isArchived --limit 200 | + jq -r --arg o "${owner}" '.[] | "\($o)/\(.name)\t\(.isArchived)"' + done + if [[ -n "${EXTRA}" ]]; then + IFS=',' read -r -a extras <<<"${EXTRA}" + for r in "${extras[@]}"; do + [[ -n "${r}" ]] && printf '%s\tfalse\n' "${r}" + done + fi +} + +# owner/repo -> prints the file's content, base64 with no newlines. +# Returns 0 with content, 1 for a confirmed HTTP 404 (the file is absent), or +# 2 with the failure message for any other failure. A 403, a rate limit, or a +# network error must NOT read as "absent": under --apply --mode=push that +# would PUT the stub onto the default branch of a repo whose state is unknown. +# Compared byte-for-byte against STUB_B64, so a +# trailing-newline difference is a real difference and shows up as DIFFERS. +# +# The API wraps .content in 60-column lines, so it is NOT comparable to +# STUB_B64 as returned. Stripping those newlines and round-tripping through +# base64 -d | base64 renormalizes it to the same single-line encoding +# STUB_B64 uses; the decode/encode pair is a canonicalization, not a +# double-encode. Comparing raw decoded text instead would not work: $(...) +# strips trailing newlines, so a correct stub could never match. +existing_b64() { + local body rc=0 + # stderr is captured, not discarded: it carries the "(HTTP )" line that + # distinguishes a real 404 from every other failure. Real `gh api` prints + # "gh: Not Found (HTTP 404)" there and exits 1 (measured 2026-09-08). + body="$("${GH}" api "repos/$1/contents/${INSTALL_PATH}" 2>&1)" || rc=$? + if [[ "${rc}" -ne 0 ]]; then + if [[ "${body}" == *"(HTTP 404)"* ]]; then + return 1 + fi + printf '%s' "${body}" + return 2 + fi + jq -r '.content' <<<"${body}" | tr -d '\n' | base64 -d | base64 | tr -d '\n' +} + +default_branch() { "${GH}" api "repos/$1" --jq .default_branch; } + +put_file() { # owner/repo branch [blob-sha] + local -a sha_arg=() + # The Contents API requires the current blob sha to overwrite an existing + # file. A retry after a partial run finds the file already on the branch. + [[ -n "${3:-}" ]] && sha_arg=(-f "sha=$3") + "${GH}" api -X PUT "repos/$1/contents/${INSTALL_PATH}" \ + -f message="ci: add standards-check.yml caller stub (non-required) + +Installs the deterministic standards check as a non-required status. +It becomes required per repo in W3 once the repo is green. + +Claude-Session: ${SESSION}" \ + -f content="${STUB_B64}" \ + "${sha_arg[@]}" \ + -f branch="$2" >/dev/null +} + +# Every step below is guarded with `|| return 1`. These functions run inside a +# command substitution in an `if`, which suspends `set -e` for everything they +# call, so an unguarded failure would fall through: an empty default branch +# would still reach the PUT, and a failed ref POST would still open a PR. +install_push() { # owner/repo + local db + db="$(default_branch "$1")" || return 1 + [[ -n "${db}" ]] || return 1 + put_file "$1" "${db}" || return 1 + echo "pushed to ${db}" +} + +# Retry-safe: a previous partial run may have left the branch, and the file on +# it, behind. Reuse both rather than failing the repo on a duplicate ref POST. +install_pr() { # owner/repo + local db sha blob="" + db="$(default_branch "$1")" || return 1 + [[ -n "${db}" ]] || return 1 + if "${GH}" api "repos/$1/git/ref/heads/${BRANCH}" >/dev/null 2>&1; then + # The branch already exists; the file may too, in which case the PUT needs + # its blob sha. A 404 here just means the branch is empty of the stub. + # The ref goes in the query string, not in -f: `gh api` switches the method + # to POST as soon as any -f parameter is added (`gh api --help`), which + # would turn this read into a failing write and silently drop the sha. + blob="$("${GH}" api "repos/$1/contents/${INSTALL_PATH}?ref=${BRANCH}" --jq .sha 2>/dev/null)" || blob="" + else + sha="$("${GH}" api "repos/$1/git/ref/heads/${db}" --jq .object.sha)" || return 1 + [[ -n "${sha}" ]] || return 1 + "${GH}" api -X POST "repos/$1/git/refs" -f ref="refs/heads/${BRANCH}" -f sha="${sha}" >/dev/null || return 1 + fi + put_file "$1" "${BRANCH}" "${blob}" || return 1 + "${GH}" pr create --repo "$1" --head "${BRANCH}" --base "${db}" \ + --title "ci: add standards-check.yml caller stub (non-required)" \ + --body "Installs \`standards-check.yml\` as a NON-required check. Branch protection is unchanged; W3 flips it to required once this repo is green. + +Reusable workflow: smartwatermelon/github-workflows \`standards-check.yml@standards-check-v1\`. + +${SESSION}" || return 1 +} + +# Dispatch table: the two install functions are reached through this map, not +# by name interpolation, so a bad --mode can never name an arbitrary function. +install_repo() { # mode owner/repo + case "$1" in + push) install_push "$2" ;; + pr) install_pr "$2" ;; + *) echo "unknown mode: $1" >&2; return 2 ;; + esac +} + +# Enumerate once into a file: a process substitution here would mask +# list_repos' exit status, so a `gh repo list` failure would read as an empty +# fleet and the script would exit 0 having done nothing. +repo_list="$(mktemp)" +trap 'rm -f "${repo_list}"' EXIT +list_repos >"${repo_list}" + +rc=0 +while IFS=$'\t' read -r repo archived; do + [[ -n "${repo}" ]] || continue + if [[ -n "${ONLY}" && "${repo}" != "${ONLY}" ]]; then continue; fi + if [[ "${archived}" == "true" ]]; then + printf 'ARCHIVED %s\n' "${repo}" + continue + fi + if is_ignored "${repo}"; then + printf 'IGNORED %s\n' "${repo}" + continue + fi + lookup_rc=0 + current="$(existing_b64 "${repo}")" || lookup_rc=$? + case "${lookup_rc}" in + 0) + if [[ "${current}" == "${STUB_B64}" ]]; then + printf 'CURRENT %s\n' "${repo}" + else + printf 'DIFFERS %s (has a non-canonical stub; not touched)\n' "${repo}" + fi + continue + ;; + 1) : ;; # confirmed 404: the file is absent, fall through to MISSING + *) + # One line per repo is the output contract; API errors are multi-line. + printf 'ERROR %s %s\n' "${repo}" "${current//$'\n'/ }" + rc=1 + continue + ;; + esac + if ! ${APPLY}; then + printf 'MISSING %s (dry run; would %s)\n' "${repo}" "${MODE}" + continue + fi + if out="$(install_repo "${MODE}" "${repo}" 2>&1)"; then + printf 'MISSING %s -> %s\n' "${repo}" "${out}" + else + printf 'ERROR %s %s\n' "${repo}" "${out//$'\n'/ }" + rc=1 + fi +done <"${repo_list}" +exit "${rc}" diff --git a/standards/caller-stub.yml b/standards/caller-stub.yml new file mode 100644 index 0000000..490616d --- /dev/null +++ b/standards/caller-stub.yml @@ -0,0 +1,9 @@ +name: Standards Check +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] +permissions: + contents: read +jobs: + standards-check: + uses: smartwatermelon/github-workflows/.github/workflows/standards-check.yml@standards-check-v1 diff --git a/tests/stub-gh/gh b/tests/stub-gh/gh new file mode 100755 index 0000000..8b88c46 --- /dev/null +++ b/tests/stub-gh/gh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Stub gh for bulk-install-standards-check.sh tests. Fixture layout: +# ${STUB_DIR}/repos/.json -> output of `gh repo list --json ...` +# ${STUB_DIR}/files/__ -> existing stub content, if the repo has one +# ${STUB_DIR}/fail/__ -> contents GET fails 403 instead of 404 +# ${STUB_DIR}/branch-exists/__ -> the chore/standards-check-stub ref exists +# ${STUB_DIR}/branch-files/__ -> file already present on that branch +# ${STUB_DIR}/calls.log -> every argv, one line each +# +# Failure output copies real `gh api`: the JSON body on stdout, a +# "gh: (HTTP )" line on stderr, exit 1. Measured 2026-09-08 +# against `gh api repos/smartwatermelon/does-not-exist-xyz-9876`. +# Archived state comes from the isArchived field in repos/.json. +set -euo pipefail +: "${STUB_DIR:?}" +# One line per invocation: arguments carry multi-line bodies (commit message, +# PR body), so escape newlines rather than break the log's one-line contract. +printf '%s\n' "${*//$'\n'/\\n}" >>"${STUB_DIR}/calls.log" +case "$1" in + repo) + # gh repo list --json name,isArchived,defaultBranchRef --limit 200 + owner="$3" + cat "${STUB_DIR}/repos/${owner}.json" + ;; + api) + shift + method="GET" + path="" + filter="" + ref_param="" + while [[ $# -gt 0 ]]; do + case "$1" in + -X) method="$2"; shift 2 ;; + --jq) filter="$2"; shift 2 ;; + -f | -F) shift 2 ;; + -*) shift ;; + *) path="$1"; shift ;; + esac + done + # A ref is only honored as a query parameter. `gh api` turns any -f into a + # POST body, so a caller that passed the ref that way would not be doing a + # branch-scoped read at all; the fixture must not pretend otherwise. + if [[ "${path}" == *"?"* ]]; then + query="${path#*\?}" + path="${path%%\?*}" + [[ "${query}" == ref=* ]] && ref_param="${query#ref=}" + fi + # Real gh applies --jq to the response and prints raw strings; do the same, + # or callers reading `--jq .default_branch` get a JSON object back. + emit() { + if [[ -n "${filter}" ]]; then + jq -r "${filter}" + else + cat + fi + } + case "${method} ${path}" in + "GET repos/"*"/contents/.github/workflows/standards-check.yml") + # repos///contents/... + rest="${path#repos/}"; owner="${rest%%/*}"; rest="${rest#*/}"; repo="${rest%%/*}" + if [[ -f "${STUB_DIR}/fail/${owner}__${repo}" ]]; then + printf '{"message":"Forbidden","status":"403"}' + echo 'gh: Forbidden (HTTP 403)' >&2 + exit 1 + fi + if [[ -n "${ref_param}" && "${ref_param}" != "main" ]]; then + # A read scoped to the feature branch: a separate fixture, so a test + # can tell "already on the branch" from "on the default branch". + bf="${STUB_DIR}/branch-files/${owner}__${repo}" + if [[ -f "${bf}" ]]; then + echo '{"sha":"branchblob"}' | emit + else + printf '{"message":"Not Found","status":"404"}' + echo 'gh: Not Found (HTTP 404)' >&2 + exit 1 + fi + exit 0 + fi + f="${STUB_DIR}/files/${owner}__${repo}" + if [[ -f "${f}" ]]; then + # The real Contents API wraps .content in 60-column lines with + # embedded "\n" escapes. Reproduce that so the caller's decode path + # is exercised as it will run in production, not a flat-base64 + # simplification that would hide a newline-handling bug. + b64="$(base64 <"${f}")" + b64="$(tr -d '\n' <<<"${b64}")" + b64="$(fold -w 60 <<<"${b64}")" + b64="${b64//$'\n'/\\n}" + printf '{"sha":"deadbeef","content":"%s"}\n' "${b64}" | emit + else + printf '{"message":"Not Found","status":"404"}' + echo 'gh: Not Found (HTTP 404)' >&2 + exit 1 + fi ;; + "GET repos/"*"/git/ref/heads/chore/standards-check-stub") + rest="${path#repos/}"; owner="${rest%%/*}"; rest="${rest#*/}"; repo="${rest%%/*}" + if [[ -f "${STUB_DIR}/branch-exists/${owner}__${repo}" ]]; then + echo '{"object":{"sha":"branchsha"}}' | emit + else + printf '{"message":"Not Found","status":"404"}' + echo 'gh: Not Found (HTTP 404)' >&2 + exit 1 + fi ;; + "GET repos/"*"/git/ref/heads/"*) + echo '{"object":{"sha":"abc123"}}' | emit ;; + "GET repos/"*) + # default-branch lookup: repos// + rest="${path#repos/}"; owner="${rest%%/*}"; rest="${rest#*/}"; repo="${rest%%/*}" + if [[ -f "${STUB_DIR}/fail-repo/${owner}__${repo}" ]]; then + printf '{"message":"Forbidden","status":"403"}' + echo 'gh: Forbidden (HTTP 403)' >&2 + exit 1 + fi + echo '{"default_branch":"main"}' | emit ;; + "PUT repos/"*"/contents/"*) + echo '{"content":{"path":".github/workflows/standards-check.yml"},"commit":{"sha":"c0ffee"}}' | emit ;; + "POST repos/"*"/git/refs") + echo '{"ref":"refs/heads/x"}' | emit ;; + *) echo "stub gh: unhandled ${method} ${path}" >&2; exit 99 ;; + esac + ;; + pr) + echo "https://github.com/stub/pr/1" + ;; + *) echo "stub gh: unhandled $*" >&2; exit 99 ;; +esac diff --git a/tests/test-bulk-install-standards-check.sh b/tests/test-bulk-install-standards-check.sh new file mode 100755 index 0000000..7c927d7 --- /dev/null +++ b/tests/test-bulk-install-standards-check.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Hermetic tests for bulk-install-standards-check.sh against tests/stub-gh/gh. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="${here}/../bulk-install-standards-check.sh" +stub="${here}/../standards/caller-stub.yml" +pass=0; fail=0 +_ok() { echo " ok $1"; pass=$((pass + 1)); } +_bad() { echo " FAIL $1"; fail=$((fail + 1)); } + +_fixture() { # creates a fresh STUB_DIR with two owners, four repos + STUB_DIR="$(mktemp -d)"; export STUB_DIR + mkdir -p "${STUB_DIR}/repos" "${STUB_DIR}/files" + cat >"${STUB_DIR}/repos/acme.json" <<'EOF' +[{"name":"alpha","isArchived":false,"defaultBranchRef":{"name":"main"}}, + {"name":"beta","isArchived":false,"defaultBranchRef":{"name":"main"}}, + {"name":"old","isArchived":true,"defaultBranchRef":{"name":"main"}}] +EOF + cat >"${STUB_DIR}/repos/nite.json" <<'EOF' +[{"name":"gamma","isArchived":false,"defaultBranchRef":{"name":"main"}}] +EOF + cp "${stub}" "${STUB_DIR}/files/acme__beta" # beta already CURRENT + printf 'name: Different\n' >"${STUB_DIR}/files/nite__gamma" # gamma DIFFERS + printf 'acme/alpha-ignored\n' >"${STUB_DIR}/ignore" + : >"${STUB_DIR}/calls.log" +} + +# --extra-repos "" is required: the script's default EXTRA names five real +# twistedmelonman repos, which the fixture does not know about. They would +# read as MISSING and add PUTs that the counts below do not expect. +run() { BULK_GH="${here}/stub-gh/gh" BULK_STUB_FILE="${stub}" BULK_IGNORE_FILE="${STUB_DIR}/ignore" bash "${script}" --owners acme,nite --extra-repos "" "$@"; } + +# 1. dry run classifies every repo and writes nothing +_fixture +out="$(run 2>&1)" || true +if grep -q '^MISSING *acme/alpha' <<<"${out}"; then _ok "alpha is MISSING"; else _bad "alpha classification: ${out}"; fi +if grep -q '^CURRENT *acme/beta' <<<"${out}"; then _ok "beta is CURRENT"; else _bad "beta classification"; fi +if grep -q '^ARCHIVED *acme/old' <<<"${out}"; then _ok "old is ARCHIVED"; else _bad "old classification"; fi +if grep -q '^DIFFERS *nite/gamma' <<<"${out}"; then _ok "gamma is DIFFERS"; else _bad "gamma classification"; fi +if grep -q 'PUT' "${STUB_DIR}/calls.log"; then _bad "dry run wrote a file"; else _ok "dry run wrote nothing"; fi + +# 2. --apply --mode=push PUTs only the MISSING repo, onto its default branch +_fixture +run --apply --mode=push >/dev/null 2>&1 || true +puts="$(grep -c 'PUT repos/' "${STUB_DIR}/calls.log" || true)" +if [[ "${puts}" == "1" ]]; then _ok "push mode: exactly one PUT"; else _bad "push mode: ${puts} PUTs"; fi +if grep -q 'PUT repos/acme/alpha/contents/.github/workflows/standards-check.yml' "${STUB_DIR}/calls.log"; then _ok "PUT targets alpha"; else _bad "PUT target"; fi +if grep 'PUT repos/acme/alpha' "${STUB_DIR}/calls.log" | grep -q 'branch=main'; then _ok "PUT goes to main"; else _bad "PUT branch"; fi +if grep -q 'git/refs' "${STUB_DIR}/calls.log"; then _bad "push mode created a branch"; else _ok "push mode: no branch"; fi + +# 3. --apply --mode=pr creates a branch, PUTs onto it, opens a PR +_fixture +run --apply --mode=pr >/dev/null 2>&1 || true +if grep -q 'POST repos/acme/alpha/git/refs' "${STUB_DIR}/calls.log"; then _ok "pr mode: branch created"; else _bad "pr mode: no branch"; fi +if grep 'PUT repos/acme/alpha' "${STUB_DIR}/calls.log" | grep -q 'branch=chore/standards-check-stub'; then _ok "PUT goes to the feature branch"; else _bad "PUT branch in pr mode"; fi +if grep -q '^pr create' "${STUB_DIR}/calls.log"; then _ok "pr mode: PR opened"; else _bad "pr mode: no PR"; fi + +# 4. DIFFERS is never written, in either mode +_fixture +run --apply --mode=push >/dev/null 2>&1 || true +if grep -q 'PUT repos/nite/gamma' "${STUB_DIR}/calls.log"; then _bad "DIFFERS was overwritten"; else _ok "DIFFERS left alone"; fi + +# 5. ignore file honored; --only restricts +_fixture +printf 'acme/alpha\n' >"${STUB_DIR}/ignore" +out="$(run 2>&1)" || true +if grep -q '^IGNORED *acme/alpha' <<<"${out}"; then _ok "ignore file honored"; else _bad "ignore file"; fi +_fixture +out="$(run --only nite/gamma 2>&1)" || true +if grep -q 'acme/' <<<"${out}"; then _bad "--only leaked other repos"; else _ok "--only restricts"; fi + +# 6. a non-404 API failure is ERROR, not MISSING, and writes nothing +# A 403 or a rate-limit must never read as "the file is absent", because in +# --apply --mode=push that would PUT the stub onto the default branch of a +# repo whose real state is unknown. +_fixture +mkdir -p "${STUB_DIR}/fail" +: >"${STUB_DIR}/fail/acme__alpha" +rc=0 +out="$(run --apply --mode=push 2>&1)" || rc=$? +if grep -q '^ERROR *acme/alpha' <<<"${out}"; then _ok "non-404 failure is ERROR"; else _bad "non-404 classification: ${out}"; fi +if grep -q 'PUT repos/acme/alpha' "${STUB_DIR}/calls.log"; then _bad "ERROR repo was written"; else _ok "ERROR repo not written"; fi +if [[ "${rc}" == "1" ]]; then _ok "non-404 failure exits 1"; else _bad "exit status ${rc}, expected 1"; fi + +# 7. pr mode is retry-safe: an existing branch is reused, not re-created +_fixture +mkdir -p "${STUB_DIR}/branch-exists" +: >"${STUB_DIR}/branch-exists/acme__alpha" +rc=0 +out="$(run --apply --mode=pr 2>&1)" || rc=$? +if grep -q 'POST repos/acme/alpha/git/refs' "${STUB_DIR}/calls.log"; then _bad "existing branch was re-created"; else _ok "existing branch reused"; fi +if grep 'PUT repos/acme/alpha' "${STUB_DIR}/calls.log" | grep -q 'branch=chore/standards-check-stub'; then _ok "retry: PUT goes to the existing branch"; else _bad "retry: PUT branch"; fi +if grep -q '^pr create' "${STUB_DIR}/calls.log"; then _ok "retry: PR opened"; else _bad "retry: no PR"; fi +if [[ "${rc}" == "0" ]]; then _ok "retry run exits 0"; else _bad "retry exit status ${rc}: ${out}"; fi + +# 8. retry with the file already on the branch passes its blob sha to the PUT +_fixture +mkdir -p "${STUB_DIR}/branch-exists" "${STUB_DIR}/branch-files" +: >"${STUB_DIR}/branch-exists/acme__alpha" +printf 'name: Partial\n' >"${STUB_DIR}/branch-files/acme__alpha" +run --apply --mode=pr >/dev/null 2>&1 || true +if grep 'PUT repos/acme/alpha' "${STUB_DIR}/calls.log" | grep -q 'sha=branchblob'; then _ok "retry: blob sha passed to PUT"; else _bad "retry: no blob sha in PUT"; fi + +# 9. a failing install step stops the repo instead of falling through +# install_push/install_pr run inside a command substitution in an `if`, which +# suspends `set -e`. Without an explicit guard on each step, a failed +# default-branch lookup would leave the branch empty and the PUT would still +# fire. +_fixture +mkdir -p "${STUB_DIR}/fail-repo" +: >"${STUB_DIR}/fail-repo/acme__alpha" +rc=0 +out="$(run --apply --mode=push 2>&1)" || rc=$? +if grep -q '^ERROR *acme/alpha' <<<"${out}"; then _ok "failed install step is ERROR"; else _bad "failed install: ${out}"; fi +if grep -q 'PUT repos/acme/alpha' "${STUB_DIR}/calls.log"; then _bad "PUT fired after a failed lookup"; else _ok "no PUT after a failed lookup"; fi +if [[ "${rc}" == "1" ]]; then _ok "failed install exits 1"; else _bad "failed install exit ${rc}"; fi + +# 10. the same guard in pr mode: no PR is opened after a failed lookup +_fixture +mkdir -p "${STUB_DIR}/fail-repo" +: >"${STUB_DIR}/fail-repo/acme__alpha" +run --apply --mode=pr >/dev/null 2>&1 || true +if grep -q '^pr create' "${STUB_DIR}/calls.log"; then _bad "PR opened after a failed lookup"; else _ok "no PR after a failed lookup"; fi + +# 11. --extra-repos adds explicit owner/repo entries outside --owners +_fixture +mkdir -p "${STUB_DIR}/repos"; printf '[]\n' >"${STUB_DIR}/repos/solo.json" +out="$(run --extra-repos solo/thing 2>&1)" || true +if grep -q '^MISSING *solo/thing' <<<"${out}"; then _ok "--extra-repos included"; else _bad "--extra-repos: ${out}"; fi + +echo "${pass} passed, ${fail} failed" +[[ "${fail}" -eq 0 ]]