Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad47152c2 | ||
|
|
023540b151 | ||
|
|
974e4740ab | ||
|
|
9cd6d39b71 | ||
|
|
143f925fd8 | ||
|
|
24294d3b77 | ||
|
|
24caeab057 | ||
|
|
888a6ad29b |
+8
-1
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "next",
|
||||
"release_branch": "main"
|
||||
"release_branch": "main",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
|
||||
"canonical_clone": "host:/src/mosaic-stack",
|
||||
"worktree_root": "host:/src/mosaic-stack-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated",
|
||||
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
|
||||
}
|
||||
|
||||
@@ -91,6 +91,15 @@ steps:
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
|
||||
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
|
||||
# fail-closed: a seat whose login is missing gets a named error, never a
|
||||
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
|
||||
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
|
||||
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
|
||||
# repo. Pins that comment BODIES render on both paths and that a tea
|
||||
# failure is named as what it was (git-config vs credential).
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
|
||||
@@ -111,7 +111,7 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
|
||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||
placed.
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
|
||||
@@ -379,8 +379,54 @@ check_fleet_transport() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_structure_anchor_provisioning() {
|
||||
# T51 WP0b (spec §1.2a + PHASE2-MAP F7): audit the two declaration anchors.
|
||||
# Doctor runs from operator shells and CI where the launcher exports do not
|
||||
# exist, so this is an AUDIT ONLY — it never exports, writes, or fabricates
|
||||
# values for consumption. Four states (charter):
|
||||
# both present+nonempty PASS (values reported as paths only)
|
||||
# one missing/empty WARN naming the var + the launcher as authority
|
||||
# neither present INFORMATIONAL launcher-equivalent derivation,
|
||||
# explicitly non-authoritative, + launcher warning;
|
||||
# never an error by design (F7(b))
|
||||
# Severity follows the doctor's existing conventions: pass/note are quiet
|
||||
# (note unless --verbose), warn counts toward --fail-on-warn.
|
||||
local host_root="${MOSAIC_HOST_ROOT:-}" brain_home="${MOSAIC_BRAIN_HOME:-}"
|
||||
# T51P2WP0BRW B1: presence is tracked SEPARATELY from value — `${VAR:-}`
|
||||
# collapses exported-empty into genuinely-unset, which mis-filed both-empty
|
||||
# and the mixed empty/unset states as informational. Only BOTH-genuinely-
|
||||
# absent may be informational (charter state 3); any present-but-empty or
|
||||
# single-present state warns.
|
||||
local host_set=0 brain_set=0
|
||||
[[ -v MOSAIC_HOST_ROOT ]] && host_set=1
|
||||
[[ -v MOSAIC_BRAIN_HOME ]] && brain_set=1
|
||||
if [[ "$host_set" -eq 1 && "$brain_set" -eq 1 && -n "$host_root" && -n "$brain_home" ]]; then
|
||||
pass "Structure anchors provisioned: MOSAIC_HOST_ROOT=$host_root MOSAIC_BRAIN_HOME=$brain_home (paths reported only; not expanded, not consumed)"
|
||||
return
|
||||
fi
|
||||
if [[ "$host_set" -eq 0 && "$brain_set" -eq 0 ]]; then
|
||||
note "Structure anchors not provisioned in this environment. Launcher-equivalent derivation (INFORMATIONAL, NON-AUTHORITATIVE — seats receive the authoritative values from the launchers): MOSAIC_HOST_ROOT would default to the operator home; MOSAIC_BRAIN_HOME would default to the brain tree resolved at launch. Doctor does not guess values for consumption; it audits provisioning."
|
||||
note "Provision both anchors via the seat launchers (launch-seat.sh / launch-seat-claude.sh export them; see T51 spec §1.2a)."
|
||||
return
|
||||
fi
|
||||
# At least one variable is present (possibly empty), or exactly one exists:
|
||||
# every missing/empty anchor gets its own loud WARN naming the launchers.
|
||||
if [[ "$host_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is not set in this environment while MOSAIC_BRAIN_HOME is — declaration consumers fail closed without it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$host_root" ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is present but EMPTY in this environment — declaration consumers fail closed without a usable value (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
if [[ "$brain_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is not set in this environment while MOSAIC_HOST_ROOT is — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$brain_home" ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is present but EMPTY in this environment — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
}
|
||||
|
||||
check_fleet_transport
|
||||
|
||||
check_structure_anchor_provisioning
|
||||
|
||||
check_brain_home
|
||||
|
||||
# Legacy migration surfaces should no longer contain symlink trees.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the structure-anchor provisioning check in `mosaic-doctor` (T51 WP0b).
|
||||
#
|
||||
# Same discipline as test-brain-home-check.sh: functions are extracted from the
|
||||
# shipped script (exact header + closing brace), never copied — a test carrying
|
||||
# its own copy of the logic keeps passing after the shipped copy changes.
|
||||
#
|
||||
# Four contract states (charter T51P2WP0B-20260824):
|
||||
# 1. both present+nonempty -> pass ([OK]), no warns, no notes
|
||||
# 2a. host missing, brain set -> warn naming MOSAIC_HOST_ROOT + launchers
|
||||
# 2b. brain missing, host set -> warn naming MOSAIC_BRAIN_HOME + launchers
|
||||
# 2c. present-but-EMPTY counts as missing (warns; NEVER informational)
|
||||
# 3. neither present -> informational notes, NON-AUTHORITATIVE, never warn
|
||||
# Arms include genuinely-UNSET (env -u) forms, not only empty strings.
|
||||
# Red control: empty-vs-unset distinction removed in a mutated copy -> suite red.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
|
||||
extract_function() {
|
||||
local name="$1"
|
||||
local extracted
|
||||
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
|
||||
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
|
||||
printf '%s\n' "$extracted"
|
||||
}
|
||||
|
||||
for fn in check_structure_anchor_provisioning; do
|
||||
extract_function "$fn" >/dev/null
|
||||
done
|
||||
|
||||
# run_case LABEL EXPECT(ok|warn|note) [env assignments as args; -u VAR tokens for unset]
|
||||
run_case() {
|
||||
local label="$1" expect="$2"
|
||||
shift 2
|
||||
local envs=() unsets=()
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-u:*) unsets+=("${a#-u:}") ;;
|
||||
*) envs+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
local out warns notes oks
|
||||
# build the env command with proper -u flags (array expansion must not
|
||||
# glue '-u VAR' into one word)
|
||||
local cmd=(env)
|
||||
local e u
|
||||
# env(1) parses options only before the first assignment — -u flags FIRST
|
||||
for u in "${unsets[@]:-}"; do [ -n "$u" ] && cmd+=(-u "$u"); done
|
||||
for e in "${envs[@]:-}"; do [ -n "$e" ] && cmd+=("$e"); done
|
||||
cmd+=(bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function check_structure_anchor_provisioning); check_structure_anchor_provisioning")
|
||||
out=$("${cmd[@]}" 2>&1)
|
||||
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
|
||||
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
|
||||
oks=$(printf '%s\n' "$out" | grep -c '^\[OK\]' || true)
|
||||
if [[ "$expect" == ok && "$oks" -gt 0 && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label"
|
||||
elif [[ "$expect" == warn && "$warns" -ge 1 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label (warned x$warns)"
|
||||
elif [[ "$expect" == note && "$notes" -gt 0 && "$warns" -eq 0 ]]; then
|
||||
echo "ok - $label (noted)"
|
||||
else
|
||||
echo "output: $out" >&2
|
||||
fail "$label: expected $expect (oks=$oks warns=$warns notes=$notes)"
|
||||
fi
|
||||
}
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
HOST="$ROOT/host"
|
||||
BRAIN="$ROOT/brain"
|
||||
|
||||
# ── state 1: both present + nonempty → pass ────────────────────────────────
|
||||
run_case "both anchors present passes" ok \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2a: host missing (unset), brain set → exactly one warn ───────────
|
||||
run_case "unset host root warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2b: brain missing (unset), host set → exactly one warn ───────────
|
||||
run_case "unset brain home warns" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" -u:MOSAIC_BRAIN_HOME
|
||||
|
||||
# ── state 2c-empty: present-but-empty counts as missing ────────────────────
|
||||
run_case "empty-string host root warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
run_case "empty-string brain home warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── state 3: neither present (genuinely unset) → notes, never warn ─────────
|
||||
run_case "both unset yields non-authoritative notes" note \
|
||||
-u:MOSAIC_HOST_ROOT -u:MOSAIC_BRAIN_HOME
|
||||
run_case "both empty-string warns (empty is present, not absent)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME=
|
||||
run_case "host empty + brain unset warns" warn \
|
||||
MOSAIC_HOST_ROOT= -u:MOSAIC_BRAIN_HOME
|
||||
run_case "host unset + brain empty warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── red control (mutation): presence tracking removed → red ────────────────
|
||||
# Mutant regresses to the reviewed defect shape: presence derived from
|
||||
# NONEMPTINESS (the `${VAR:-}` collapse) instead of true -v tracking. Both-empty
|
||||
# then looks genuinely-absent and is mis-filed as informational; the both-empty
|
||||
# warn arm above finds no WARN and the suite reds.
|
||||
MUT="$ROOT/mosaic-doctor.mutant"
|
||||
sed 's/\[\[ -v MOSAIC_HOST_ROOT \]\] \&\& host_set=1/[[ -n "${MOSAIC_HOST_ROOT:-}" ]] \&\& host_set=1/; s/\[\[ -v MOSAIC_BRAIN_HOME \]\] \&\& brain_set=1/[[ -n "${MOSAIC_BRAIN_HOME:-}" ]] \&\& brain_set=1/' \
|
||||
"$DOCTOR" > "$MUT"
|
||||
if cmp -s "$DOCTOR" "$MUT"; then
|
||||
echo "SKIP red control (mutation anchor not found — sed pattern drifted)" >&2
|
||||
else
|
||||
mut_fn=$(sed -n "/^check_structure_anchor_provisioning() {/,/^}/p" "$MUT")
|
||||
outm=$(env MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME= bash -c \
|
||||
"warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $mut_fn; check_structure_anchor_provisioning" 2>&1)
|
||||
if printf '%s\n' "$outm" | grep -q '^\[NOTE\]'; then
|
||||
echo "ok - red control bites (mutant collapses empty into informational; shipped does not)"
|
||||
else
|
||||
fail "red control did not reproduce the regression shape (mutant output unexpected)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "structure anchor doctor check: all arms passed"
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# seat-logins.sh — project seat credentials into tea's login config.
|
||||
#
|
||||
# Issue: mosaicstack/stack#1356 (tea login resolution fails open).
|
||||
#
|
||||
# WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a
|
||||
# login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the
|
||||
# seat secrets dir on the tea path. The secrets dir stays authoritative and this
|
||||
# script projects it into tea's config, which is a DERIVED CACHE: regenerate it,
|
||||
# never hand-edit it. Same shape as the config-registry projector, same reason —
|
||||
# a third-party tool that cannot read our store has to be fed.
|
||||
#
|
||||
# Canonical login name is "<instance>-<seat>", which is what the identity ladder in
|
||||
# detect-platform.sh computes from the seat name. A login the ladder cannot compute
|
||||
# is a fail-open surface, so an ad-hoc name is a defect, not a style.
|
||||
#
|
||||
# COLLISIONS. tea refuses to store one token under two names ("token already been
|
||||
# used, delete login 'X' first"). A hand-made alias holding a seat's token there-
|
||||
# fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows
|
||||
# it; --adopt resolves it by deleting the alias and re-minting canonically. Same
|
||||
# token, same access, only the label changes.
|
||||
#
|
||||
# Tokens are never printed, never logged, and never passed on a visible command
|
||||
# line beyond tea's own --token, which is unavoidable with this client. tea's
|
||||
# stderr is echoed on failure with any token-shaped string redacted.
|
||||
#
|
||||
# Usage:
|
||||
# seat-logins.sh # dry run, all seats (default: changes nothing)
|
||||
# seat-logins.sh --apply # mint/refresh all seats
|
||||
# seat-logins.sh --seat <seat> # limit to one seat
|
||||
# seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names
|
||||
set -euo pipefail
|
||||
|
||||
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}"
|
||||
APPLY=0
|
||||
ADOPT=0
|
||||
ONLY_SEAT=""
|
||||
|
||||
# Instance -> server URL.
|
||||
#
|
||||
# Instances are named here because there is no registry to read them from yet.
|
||||
# Override per-instance without editing this file, which is how a deployment adds
|
||||
# its own hosts: MOSAIC_GITEA_URL_<INSTANCE>=https://...
|
||||
declare -A INSTANCE_URL=(
|
||||
[mosaicstack]="https://git.mosaicstack.dev"
|
||||
[usc]="https://git.uscllc.com"
|
||||
)
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=1; shift ;;
|
||||
--adopt) ADOPT=1; shift ;;
|
||||
--seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;;
|
||||
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
||||
*) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; }
|
||||
|
||||
url_for() {
|
||||
local inst="$1" ovr
|
||||
ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')"
|
||||
if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi
|
||||
printf '%s' "${INSTANCE_URL[$inst]:-}"
|
||||
}
|
||||
|
||||
# Redact anything token-shaped before any tea output reaches a log.
|
||||
redact() { sed -E 's/[A-Za-z0-9]{30,}/<REDACTED>/g'; }
|
||||
|
||||
# token sha256 -> login name, for every login tea already holds. This is what
|
||||
# makes collisions visible in a DRY RUN instead of only as an apply-time error.
|
||||
declare -A TOKEN_OWNER=()
|
||||
if [ -r "$TEA_CONFIG" ]; then
|
||||
while read -r sha lname; do
|
||||
[ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname"
|
||||
done < <(python3 - "$TEA_CONFIG" <<'PY'
|
||||
import sys, yaml, hashlib
|
||||
try:
|
||||
cfg = yaml.safe_load(open(sys.argv[1])) or {}
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for l in (cfg.get('logins') or []):
|
||||
t = l.get('token')
|
||||
if t:
|
||||
print(hashlib.sha256(t.encode()).hexdigest(), l.get('name'))
|
||||
PY
|
||||
)
|
||||
fi
|
||||
|
||||
minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0
|
||||
|
||||
existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
shopt -s nullglob
|
||||
for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do
|
||||
seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}"
|
||||
[ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue
|
||||
|
||||
base="$(basename "$tokfile" .token)" # gitea-<instance>-<seat>
|
||||
inst="${base#gitea-}"; inst="${inst%-"$seat"}"
|
||||
name="${inst}-${seat}"
|
||||
url="$(url_for "$inst")"
|
||||
|
||||
if [ -z "$url" ]; then
|
||||
echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})"
|
||||
skipped=$((skipped+1)); continue
|
||||
fi
|
||||
if [ ! -r "$tokfile" ]; then
|
||||
echo " SKIP $name — token not readable"
|
||||
skipped=$((skipped+1)); continue
|
||||
fi
|
||||
|
||||
action="mint"
|
||||
grep -qx "$name" <<<"$existing" && action="refresh"
|
||||
|
||||
# Is this exact token already stored under some OTHER name?
|
||||
tsha="$(sha256sum < "$tokfile" | awk '{print $1}')"
|
||||
owner="${TOKEN_OWNER[$tsha]:-}"
|
||||
collision=""
|
||||
[ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner"
|
||||
|
||||
if [ "$APPLY" -eq 0 ]; then
|
||||
if [ -n "$collision" ]; then
|
||||
if [ "$ADOPT" -eq 1 ]; then
|
||||
echo " PLAN adopt $collision -> $name ($url)"
|
||||
else
|
||||
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
||||
blocked=$((blocked+1)); continue
|
||||
fi
|
||||
else
|
||||
echo " PLAN $action $name -> $url"
|
||||
fi
|
||||
planned=$((planned+1)); continue
|
||||
fi
|
||||
|
||||
if [ -n "$collision" ]; then
|
||||
if [ "$ADOPT" -eq 0 ]; then
|
||||
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
||||
blocked=$((blocked+1)); continue
|
||||
fi
|
||||
tea login delete "$collision" >/dev/null 2>&1 || true
|
||||
action="adopt"
|
||||
fi
|
||||
|
||||
# tea has no idempotent add; refresh is delete-then-add so a rotated token lands.
|
||||
[ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true
|
||||
|
||||
if err="$(tea login add --name "$name" --url "$url" \
|
||||
--token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then
|
||||
case "$action" in
|
||||
mint) minted=$((minted+1)) ;;
|
||||
refresh) refreshed=$((refreshed+1)) ;;
|
||||
adopt) adopted=$((adopted+1)) ;;
|
||||
esac
|
||||
if [ "$action" = adopt ]; then
|
||||
echo " OK adopt $collision -> $name ($url)"
|
||||
else
|
||||
echo " OK $action $name -> $url"
|
||||
fi
|
||||
else
|
||||
# A failure here is real information: the seat's token is dead, or the server
|
||||
# refused it. Do not paper over it; the seat cannot act until it is reminted.
|
||||
# tea's own words, redacted — a summarised FAIL hides whether the cause is the
|
||||
# credential or the client, which cost a diagnosis on 2026-08-21.
|
||||
echo " FAIL $action $name -> $url"
|
||||
echo " tea: $(printf '%s' "$err" | redact | head -1)"
|
||||
failed=$((failed+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$APPLY" -eq 0 ]; then
|
||||
echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked."
|
||||
[ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names."
|
||||
echo "no changes made. re-run with --apply."
|
||||
else
|
||||
echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed"
|
||||
fi
|
||||
[ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]
|
||||
@@ -102,6 +102,36 @@ of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is de
|
||||
environment variable that restores the fallback; one would reintroduce exactly the
|
||||
substitution this removes.
|
||||
|
||||
### The tea path: login resolution (#1356)
|
||||
|
||||
The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`,
|
||||
`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea
|
||||
0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers
|
||||
therefore resolve a login name, not a token, and the resolution follows the same identity
|
||||
as above:
|
||||
|
||||
1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`).
|
||||
2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev` → `mosaicstack`,
|
||||
`git.uscllc.com` → `usc`), or from the owner when `--repo owner/name` is given.
|
||||
3. The canonical login is `<instance>-<identity>`. If tea has it, that login acts.
|
||||
4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero
|
||||
exit, empty stdout, and a stderr line naming the login it wanted and the source of the
|
||||
identity. When `tea` itself is not installed the message says so instead, since "no such
|
||||
login" would send the reader to create a login they cannot create.
|
||||
5. With **no identity set**, the old host-default behaviour is unchanged (first login
|
||||
configured for that host, else the API fallback).
|
||||
|
||||
Step 4 replaced a fallback that picked any login configured for the host, which meant a
|
||||
seat with no login of its own silently acted as whichever seat had configured one. That
|
||||
satisfied the author≠reviewer gate on paper while one actor held both names.
|
||||
|
||||
**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from
|
||||
its secrets store into tea's config under the canonical name. tea's config is a derived
|
||||
cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with
|
||||
`--seat <seat>` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made
|
||||
alias holding a seat's token blocks its canonical name (tea refuses one token under two
|
||||
names); `--adopt` renames it.
|
||||
|
||||
### Enabling it for a clone
|
||||
|
||||
The framework installer syncs `git-credential-mosaic` to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ci-queue-wait.sh - Wait until project CI queue is clear (no running/queued pipeline on branch head)
|
||||
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,10 +14,11 @@ TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
REQUIRE_STATUS=0
|
||||
NO_CI_EXPECTED=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
@@ -27,6 +28,7 @@ Options:
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
--require-status Fail if no CI status contexts are present
|
||||
--no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin); refused with exit 78 when MOSAIC_GIT_IDENTITY is unset or empty
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
@@ -175,6 +177,50 @@ PY
|
||||
return 0
|
||||
}
|
||||
|
||||
# Durable audit record for an explicit no-CI assertion event (granted or
|
||||
# refused). Same JSONL sink and field shape as record_cannot_assert so one
|
||||
# reader covers all three outcomes; the outcome value distinguishes them.
|
||||
# rc 70 on an unwritable sink: a merge pass that cannot be audited must not
|
||||
# be reachable, mirroring record_cannot_assert's refusal of a degraded pass.
|
||||
record_assertion_event() {
|
||||
local outcome="$1" reason="$2" asserted_by="$3"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: could not write ${outcome} audit record (audit directory unavailable at ${audit_log})." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$outcome" "$reason" "$asserted_by" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, outcome, reason, asserted_by, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": outcome,
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
"asserted_by": asserted_by,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: could not write ${outcome} audit record at ${audit_log}; refusing to proceed unaudited." >&2
|
||||
return 70
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
@@ -182,6 +228,24 @@ github_get_branch_head_sha() {
|
||||
gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha'
|
||||
}
|
||||
|
||||
# Repository-admin state for the acting credential, GitHub flavor. The
|
||||
# repository object's permissions.admin is the field; read through the same
|
||||
# gh CLI the guard already authenticates with. rc 0 = admin, 1 = not admin
|
||||
# (or field absent), 2 = indeterminate (transport/API failure).
|
||||
github_repo_admin_state() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local perm
|
||||
if ! perm=$(gh api "repos/${owner}/${repo}" --jq '.permissions.admin' 2>/dev/null); then
|
||||
return 2
|
||||
fi
|
||||
case "$perm" in
|
||||
true) return 0 ;;
|
||||
false|null|"") return 1 ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
@@ -306,6 +370,41 @@ gitea_get_commit_status_json() {
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
# Repository-admin state for the acting credential, Gitea flavor. The guard's
|
||||
# existing fetches (branch head, combined status) carry no permissions object
|
||||
# (measured: neither response includes one), so the elevation check reads the
|
||||
# repository object's permissions.admin, the one documented carrier of that
|
||||
# field. rc 0 = admin, 1 = not admin (or field absent), 2 = indeterminate
|
||||
# (non-200 or unparseable).
|
||||
gitea_repo_admin_state() {
|
||||
local host="$1"
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}"
|
||||
local resp code body
|
||||
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
return 2
|
||||
fi
|
||||
printf '%s' "$body" | python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit(2)
|
||||
if not isinstance(payload, dict):
|
||||
raise SystemExit(2)
|
||||
permissions = payload.get("permissions")
|
||||
if not isinstance(permissions, dict) or permissions.get("admin") is not True:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-B|--branch)
|
||||
@@ -336,6 +435,10 @@ while [[ $# -gt 0 ]]; do
|
||||
REQUIRE_STATUS=1
|
||||
shift
|
||||
;;
|
||||
--no-ci-expected)
|
||||
NO_CI_EXPECTED=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -365,6 +468,10 @@ if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: --no-ci-expected and --require-status contradict each other: one asserts the repository has no CI, the other demands status contexts. Pass at most one." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
@@ -484,6 +591,48 @@ while true; do
|
||||
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$NO_CI_EXPECTED" -eq 1 ]]; then
|
||||
# Explicit, elevated, audit-visible assertion that this
|
||||
# repository has no CI to wait on. The zero-context case is
|
||||
# the ONLY state the flag reclassifies: a pending or failed
|
||||
# context still holds or fails exactly as without it, and a
|
||||
# non-admin token is refused rather than trusted.
|
||||
# The assertion must name an asserting identity: "unknown"
|
||||
# attributes nothing, so a caller with MOSAIC_GIT_IDENTITY
|
||||
# unset or empty is refused (exit 78) BEFORE the permission
|
||||
# lookup -- an unattributable caller never triggers that
|
||||
# network call.
|
||||
if [[ -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
|
||||
record_assertion_event "ASSERTION_UNATTRIBUTABLE" "actor-unattributable" "unknown" \
|
||||
|| echo "Warning: could not write the ASSERTION_UNATTRIBUTABLE audit record; the refusal itself stands." >&2
|
||||
echo "Error: ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires MOSAIC_GIT_IDENTITY to name the asserting identity and it is unset or empty (exit 78)." >&2
|
||||
exit 78
|
||||
fi
|
||||
ASSERTED_BY="${MOSAIC_GIT_IDENTITY}"
|
||||
ADMIN_STATE=2
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
|
||||
else
|
||||
if gitea_repo_admin_state "$HOST" "$OWNER/$REPO" "$TOKEN"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
|
||||
fi
|
||||
case "$ADMIN_STATE" in
|
||||
0)
|
||||
record_assertion_event "NO_CI_ASSERTED" "no-ci-expected" "$ASSERTED_BY" || exit $?
|
||||
echo "[ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}"
|
||||
exit 0
|
||||
;;
|
||||
1)
|
||||
record_assertion_event "ASSERTION_REFUSED" "actor-not-repo-admin" "$ASSERTED_BY" \
|
||||
|| echo "Warning: could not write the ASSERTION_REFUSED audit record; the refusal itself stands." >&2
|
||||
echo "Error: ASSERTION_REFUSED state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires repository admin and the acting token is not an admin of ${OWNER}/${REPO} (exit 77)." >&2
|
||||
exit 77
|
||||
;;
|
||||
*)
|
||||
record_cannot_assert "repo-permissions-unavailable"
|
||||
exit $?
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
|
||||
@@ -180,6 +180,66 @@ raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Map a host to the instance prefix used in canonical tea login names
|
||||
# ("<instance>-<identity>"). This deliberately mirrors the _idpfx case in
|
||||
# get_gitea_token(): the two credential paths must agree on what a host is called,
|
||||
# or an agent authenticates as itself on one path and as somebody else on the other.
|
||||
gitea_instance_for_host() {
|
||||
case "${1:-}" in
|
||||
git.uscllc.com) echo usc ;;
|
||||
git.mosaicstack.dev) echo mosaicstack ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Resolve the acting git identity, same precedence as get_gitea_token() step 0.
|
||||
# Prints "<identity>\t<source>" so the caller can name the source in an error.
|
||||
resolve_git_identity() {
|
||||
local ident src
|
||||
ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
src="MOSAIC_GIT_IDENTITY"
|
||||
if [[ -z "$ident" ]]; then
|
||||
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
[[ -n "$ident" ]] || return 1
|
||||
printf '%s\t%s\n' "$ident" "$src"
|
||||
}
|
||||
|
||||
# Map a repo owner to an instance. Used only by the --repo override path, which
|
||||
# has an owner and no host. Previously lived inline in lane-brief.sh; one copy so
|
||||
# the two override callers cannot drift apart.
|
||||
gitea_instance_for_owner() {
|
||||
local owner="${1:-}"
|
||||
owner="${owner%%/*}"
|
||||
case "$owner" in
|
||||
usc|USC) echo usc ;;
|
||||
mosaicstack|mosaic) echo mosaicstack ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Does a login of this name exist at all? The --repo override path cannot check
|
||||
# host agreement, because it has no host.
|
||||
tea_login_exists() {
|
||||
local login_name="$1"
|
||||
local logins_json
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
|
||||
import json, os, sys
|
||||
want = sys.argv[1]
|
||||
try:
|
||||
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
for login in logins if isinstance(logins, list) else []:
|
||||
if str(login.get("name") or login.get("Name") or "") == want:
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY_INNER
|
||||
}
|
||||
|
||||
tea_login_matches_host() {
|
||||
local login_name="$1" host="$2"
|
||||
local logins_json
|
||||
@@ -276,6 +336,40 @@ get_gitea_login_for_host() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# IDENTITY LADDER (#1356). Below this point the old code took the FIRST login
|
||||
# matching the host, which is not an identity — with 43 logins on a fleet host,
|
||||
# ~22 match one server, so a seat with no login of its own silently acted as
|
||||
# whichever happened to be first in ~/.config/tea/config.yml. Gate 16 depends on
|
||||
# author != reviewer, and borrowing satisfies it mechanically while violating it
|
||||
# in fact. The token path already refuses to borrow; this is the same refusal.
|
||||
#
|
||||
# Enforced ONLY when an identity is resolvable, exactly like get_gitea_token():
|
||||
# no identity means a human at a terminal, and neither path enforces there.
|
||||
local ident ident_src inst canon
|
||||
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
|
||||
if inst=$(gitea_instance_for_host "$host"); then
|
||||
canon="${inst}-${ident}"
|
||||
if tea_login_matches_host "$canon" "$host"; then
|
||||
echo "$canon"
|
||||
return 0
|
||||
fi
|
||||
# Say which of the two it is. "No such login" when tea is simply not
|
||||
# installed is a diagnosis of a cause that was never checked, and it
|
||||
# sends the reader off to create a login they cannot create.
|
||||
if ! command -v tea >/dev/null 2>&1; then
|
||||
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but tea is not installed," >&2
|
||||
echo " so no login can be resolved. Refusing to guess an identity." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no tea login named '$canon' exists." >&2
|
||||
echo " Refusing to borrow another login. Acting as a different identity would satisfy gate 16 mechanically while violating it." >&2
|
||||
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
|
||||
return 1
|
||||
fi
|
||||
# Identity known but the host is not a Mosaic instance. Fall through: the
|
||||
# canonical name is undefined for it, so there is nothing to enforce.
|
||||
fi
|
||||
|
||||
login=$(find_tea_login_for_host "$host" || true)
|
||||
if [[ -n "$login" ]]; then
|
||||
echo "$login"
|
||||
@@ -351,14 +445,49 @@ raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Resolve a login for an explicit --repo override, which supplies an owner and no
|
||||
# host. Takes "owner" or "owner/repo".
|
||||
#
|
||||
# The old body fell through to get_default_tea_login(), which returns the
|
||||
# default-marked login or, failing that, the first login of ANY host — arbitrary
|
||||
# identity, chosen by config file order. That is the #1356 fail-open in its worst
|
||||
# form, because unlike the host path it does not even constrain the server.
|
||||
get_gitea_login_for_repo_override() {
|
||||
local login
|
||||
local owner="${1:-}"
|
||||
local login ident ident_src inst canon
|
||||
|
||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
echo "$GITEA_LOGIN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
|
||||
if inst=$(gitea_instance_for_owner "$owner"); then
|
||||
canon="${inst}-${ident}"
|
||||
if tea_login_exists "$canon"; then
|
||||
echo "$canon"
|
||||
return 0
|
||||
fi
|
||||
# Same split as the host path above (#1357 S1): a missing tea binary
|
||||
# is not a missing login, and the "create it with" advice cannot be
|
||||
# followed without tea.
|
||||
if ! command -v tea >/dev/null 2>&1; then
|
||||
echo "Error: git identity '$ident' (via $ident_src) requested for owner '${owner%%/*}', but tea is not installed," >&2
|
||||
echo " so no login can be resolved. Refusing to guess an identity." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: git identity '$ident' (via $ident_src) has no tea login '$canon' for owner '${owner%%/*}'." >&2
|
||||
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: git identity '$ident' (via $ident_src) is set, but owner '${owner%%/*}' maps to no known instance," >&2
|
||||
echo " so the login name cannot be derived. Refusing to fall back to an arbitrary login." >&2
|
||||
echo " Set GITEA_LOGIN to name the login explicitly." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# No identity: a human at a terminal. Unchanged, and the same place the token
|
||||
# path stops enforcing.
|
||||
login=$(get_default_tea_login || true)
|
||||
if [[ -n "$login" ]]; then
|
||||
echo "$login"
|
||||
|
||||
@@ -99,8 +99,8 @@ case "$PLATFORM" in
|
||||
;;
|
||||
gitea)
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
||||
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
|
||||
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# issue-view.sh - View issue details on GitHub or Gitea
|
||||
# issue-view.sh - View issue details, including comments, on GitHub or Gitea
|
||||
# Usage: issue-view.sh -i <issue_number>
|
||||
|
||||
set -e
|
||||
@@ -28,11 +28,47 @@ gitea_issue_view_api() {
|
||||
}
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
|
||||
else
|
||||
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
# No renderer: raw JSON is all this path can give. Comments are a
|
||||
# second resource, so fetch them too rather than only the count.
|
||||
curl "${curl_args[@]}" "$url"
|
||||
curl "${curl_args[@]}" "${url}/comments"
|
||||
return
|
||||
fi
|
||||
# Render issue + comments as text (#1357 F2). The old fallback dumped the
|
||||
# issue JSON, which carries only a comment COUNT, so every comment body was
|
||||
# invisible on this path and the wrapper could never show what
|
||||
# `tea issues --comments` shows.
|
||||
{
|
||||
curl "${curl_args[@]}" "$url"
|
||||
echo
|
||||
echo "__MOSAIC_COMMENTS__"
|
||||
curl "${curl_args[@]}" "${url}/comments"
|
||||
} | python3 -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read()
|
||||
issue_raw, _, comments_raw = raw.partition("__MOSAIC_COMMENTS__")
|
||||
issue = json.loads(issue_raw)
|
||||
comments = json.loads(comments_raw) if comments_raw.strip() else []
|
||||
print("#%s %s" % (issue["number"], issue["title"]))
|
||||
print("State: %s Author: %s Created: %s" % (issue["state"], issue["user"]["login"], issue["created_at"]))
|
||||
labels = ", ".join(l["name"] for l in issue.get("labels") or [])
|
||||
if labels:
|
||||
print("Labels: " + labels)
|
||||
if issue.get("milestone"):
|
||||
print("Milestone: " + issue["milestone"]["title"])
|
||||
print("URL: " + issue["html_url"])
|
||||
print()
|
||||
print(issue.get("body") or "(no body)")
|
||||
if comments:
|
||||
print()
|
||||
print("--- Comments (%d) ---" % len(comments))
|
||||
for c in comments:
|
||||
print()
|
||||
print("[%s at %s]" % (c["user"]["login"], c["created_at"]))
|
||||
print(c.get("body") or "")
|
||||
'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -46,6 +82,8 @@ while [[ $# -gt 0 ]]; do
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo ""
|
||||
echo "Comments are always included (tea --comments / Gitea API /comments)."
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
@@ -67,11 +105,30 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue view "$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if command -v tea >/dev/null 2>&1; then
|
||||
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
|
||||
# --comments is what makes tea print the comment bodies (#1357 F3).
|
||||
# Without it tea prompts for them interactively, which in a
|
||||
# non-interactive wrapper means they are silently never shown.
|
||||
tea_err=$(mktemp)
|
||||
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args) --comments 2>"$tea_err"; then
|
||||
rm -f "$tea_err"
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
# Name the cause tea actually reported, not a guessed one (#1357 F1/F4).
|
||||
# tea reads the cwd's git config before honouring --repo; a repo with
|
||||
# extensions.worktreeconfig=true makes it exit 1 with a
|
||||
# repositoryformatversion error. That is a git-config condition, not a
|
||||
# credential one. The old path printed the REVOKED OR STALE TOKEN note
|
||||
# here unconditionally, which sent readers to rotate a token that was fine.
|
||||
if grep -q 'repositoryformatversion' "$tea_err"; then
|
||||
echo "Warning: tea cannot read this repo's git config (extensions.worktreeconfig); not a credential problem. Using Gitea API fallback." >&2
|
||||
elif grep -q 'user does not exist' "$tea_err"; then
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
else
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
fi
|
||||
sed 's/^/ tea: /' "$tea_err" >&2
|
||||
rm -f "$tea_err"
|
||||
fi
|
||||
gitea_issue_view_api
|
||||
else
|
||||
|
||||
@@ -49,11 +49,27 @@ if [[ -z "$LOGIN" ]]; then
|
||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
LOGIN="$GITEA_LOGIN"
|
||||
else
|
||||
case "${REPO%%/*}" in
|
||||
usc|USC) LOGIN=usc ;;
|
||||
mosaicstack|mosaic) LOGIN=mosaicstack ;;
|
||||
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
|
||||
esac
|
||||
# #1356: the owner-derived map below picks a SHARED login (bare `usc` /
|
||||
# `mosaicstack`). On a seat that is borrowing another identity, which is
|
||||
# exactly what gate 16 forbids. So the identity ladder goes first and the
|
||||
# map is only the no-identity fallback (a human at a terminal), which is
|
||||
# where the token path stops enforcing too.
|
||||
if LOGIN="$(get_gitea_login_for_repo_override "$REPO")"; then
|
||||
:
|
||||
elif resolve_git_identity >/dev/null 2>&1; then
|
||||
# A git identity IS set and the ladder still could not resolve a login.
|
||||
# The named reason is already on stderr. Falling through to the map here
|
||||
# would hand this seat a SHARED login (bare `usc` / `mosaicstack`) — the
|
||||
# identity-borrowing #1356 exists to stop. Fail closed instead.
|
||||
exit 2
|
||||
else
|
||||
# No identity: a human at a terminal. Owner-derived map, unchanged. This
|
||||
# is the same point at which the token path stops enforcing.
|
||||
case "${REPO%%/*}" in
|
||||
usc|USC) LOGIN=usc ;;
|
||||
mosaicstack|mosaic) LOGIN=mosaicstack ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
||||
|
||||
@@ -19,6 +19,41 @@ ISSUE=""
|
||||
|
||||
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
||||
|
||||
gitea_default_branch() {
|
||||
# Forge default branch for the current repo (T51-P2 WP5a / spec E4): the
|
||||
# API fallback must not guess a base. Empty output or any lookup failure
|
||||
# returns nonzero so the caller fails loud instead of mistargeting a PR.
|
||||
local host repo token url body branch
|
||||
host=$(get_remote_host) || return 1
|
||||
repo=$(get_repo_info) || return 1
|
||||
token=$(get_gitea_token "$host") || return 1
|
||||
url="https://${host}/api/v1/repos/${repo}"
|
||||
# Fetch and parse as separate steps (T51P2WP5AR B2): a piped
|
||||
# `curl | python` reports only python's status, so an HTTP failure that
|
||||
# still emits parseable JSON would masquerade as success. curl's own
|
||||
# exit status is authoritative here.
|
||||
if ! body=$(curl -fsS \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
"$url" 2>/dev/null); then
|
||||
return 1
|
||||
fi
|
||||
# A valid base is a NONBLANK JSON STRING (T51P2WP5AR B3): null, numbers,
|
||||
# and whitespace-only values are failed resolution, never a POSTed base.
|
||||
branch=$(printf '%s' "$body" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
value = json.load(sys.stdin).get("default_branch")
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
sys.exit(1)
|
||||
print(value.strip())
|
||||
' 2>/dev/null) || return 1
|
||||
[[ -n "$branch" ]] || return 1
|
||||
printf '%s' "$branch"
|
||||
}
|
||||
|
||||
gitea_pr_create_api() {
|
||||
local host repo token url payload
|
||||
host=$(get_remote_host) || {
|
||||
@@ -38,14 +73,28 @@ gitea_pr_create_api() {
|
||||
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||
fi
|
||||
|
||||
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
|
||||
# Base resolution (spec E4): an explicit -B always wins; with none, the
|
||||
# forge default branch is resolved from the provider API -- never the
|
||||
# historical "main" literal, which mistargeted every fallback PR on
|
||||
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
|
||||
local api_base=""
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
api_base="$BASE_BRANCH"
|
||||
else
|
||||
api_base=$(gitea_default_branch) || {
|
||||
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" API_BASE="$api_base" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = {
|
||||
"title": os.environ["TITLE"],
|
||||
"head": os.environ["HEAD_BRANCH"],
|
||||
"base": os.environ["BASE_BRANCH"] or "main",
|
||||
"base": os.environ["API_BASE"],
|
||||
}
|
||||
body = os.environ.get("BODY", "")
|
||||
if body:
|
||||
@@ -72,7 +121,7 @@ Create a pull request on the current repository (Gitea or GitHub).
|
||||
Options:
|
||||
-t, --title TITLE PR title (required, or use --issue)
|
||||
-b, --body BODY PR description/body
|
||||
-B, --base BRANCH Base branch to merge into (default: main/master)
|
||||
-B, --base BRANCH Base branch to merge into (default: the forge repository's default branch)
|
||||
-H, --head BRANCH Head branch with changes (default: current branch)
|
||||
-l, --labels LABELS Comma-separated labels
|
||||
-m, --milestone NAME Milestone name
|
||||
|
||||
@@ -94,8 +94,8 @@ case "$PLATFORM" in
|
||||
;;
|
||||
gitea)
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
||||
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
|
||||
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -16,6 +16,7 @@ DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
CO_AUTHOR_TRAILERS=false
|
||||
ESCALATE_TO=""
|
||||
NO_CI_EXPECTED=false
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -29,6 +30,7 @@ Options:
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
|
||||
--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
|
||||
@@ -70,6 +72,10 @@ while [[ $# -gt 0 ]]; do
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-ci-expected)
|
||||
NO_CI_EXPECTED=true
|
||||
shift
|
||||
;;
|
||||
--co-author-trailers)
|
||||
CO_AUTHOR_TRAILERS=true
|
||||
shift
|
||||
@@ -154,13 +160,18 @@ if [[ "$DRY_RUN" != true ]]; then
|
||||
if [[ -z "$BASE_REPO" ]]; then
|
||||
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
|
||||
fi
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$BASE_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
guard_args=(
|
||||
--purpose merge
|
||||
-B "$HEAD_BRANCH"
|
||||
-R "$BASE_REPO"
|
||||
--sha "$HEAD_SHA"
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
)
|
||||
if [[ "$NO_CI_EXPECTED" == true ]]; then
|
||||
guard_args+=(--no-ci-expected)
|
||||
fi
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" "${guard_args[@]}"
|
||||
fi
|
||||
|
||||
PLATFORM=$(detect_platform)
|
||||
|
||||
@@ -59,8 +59,8 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
||||
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
|
||||
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for ci-queue-wait.sh's --no-ci-expected assertion:
|
||||
# the sanctioned merge path for a repository with no CI configured at all.
|
||||
#
|
||||
# Zero status contexts ("no-status") stays fail-closed for --purpose merge
|
||||
# by default, because at merge time no-status can also mean "CI has not
|
||||
# reported yet". --no-ci-expected reclassifies ONLY that zero-context case
|
||||
# as queue-clear, and only for a caller whose acting token holds repository
|
||||
# admin. This harness pins:
|
||||
# (a) merge + no-status + flag + admin -> exit 0, audit line + JSONL.
|
||||
# (b) merge + no-status, no flag -> exit 3, existing text (unchanged).
|
||||
# (c) merge + no-status + flag + non-admin -> exit 77 ASSERTION_REFUSED
|
||||
# (distinct text, exit code NOT 3) + JSONL refusal record.
|
||||
# (c2) flag + admin payload without the admin field -> fail closed as (c).
|
||||
# (d) flag + --require-status -> usage error, before any network.
|
||||
# (e) flag + a real pending context -> still holds (timeout 124),
|
||||
# and the admin endpoint is never consulted.
|
||||
# (f) push + no-status, with and without the flag -> push queue-clear
|
||||
# unchanged; no admin consultation on push.
|
||||
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75),
|
||||
# not a silent pass and not a refusal.
|
||||
# (h) flag + admin stub + NO MOSAIC_GIT_IDENTITY -> refusal BEFORE
|
||||
# queue-clear and BEFORE the admin lookup: exit 78, no queue-clear
|
||||
# line, an ASSERTION_UNATTRIBUTABLE JSONL record, no repos/ call.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
URL_LOG="$WORK_DIR/urls.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
# Same stub conventions as test-ci-queue-wait-no-status.sh; adds the
|
||||
# repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
url=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_w=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_w" == 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*/status)
|
||||
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
|
||||
case "$mode" in
|
||||
no-status) body='{"state":"","statuses":[]}' ;;
|
||||
real-pending) body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}' ;;
|
||||
*) echo "curl stub: unknown status mode=$mode" >&2; exit 2 ;;
|
||||
esac
|
||||
printf '%s' "$body"
|
||||
exit 0
|
||||
;;
|
||||
*/repos/*)
|
||||
mode="${MOSAIC_STUB_ADMIN_MODE:?MOSAIC_STUB_ADMIN_MODE not set}"
|
||||
case "$mode" in
|
||||
admin) body='{"permissions":{"admin":true,"push":true,"pull":true}}' ;;
|
||||
non-admin) body='{"permissions":{"admin":false,"push":true,"pull":true}}' ;;
|
||||
no-admin-field) body='{"permissions":{}}' ;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "curl stub: unknown admin mode=$mode" >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "$has_w" == 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "curl stub: unrecognized URL: $url" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
failures=0
|
||||
|
||||
run_guard() {
|
||||
local name="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
|
||||
export MOSAIC_STUB_URL_LOG="$URL_LOG"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_GIT_IDENTITY="test-identity"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
# The suite exports test-identity globally, so the unattributable-caller
|
||||
# case must strip it from the child environment at invocation with env -u,
|
||||
# not rely on the export order.
|
||||
run_guard_no_identity() {
|
||||
local name="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
|
||||
export MOSAIC_STUB_URL_LOG="$URL_LOG"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_GIT_IDENTITY="test-identity"
|
||||
env -u MOSAIC_GIT_IDENTITY \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
expect_rc() {
|
||||
local name="$1" want="$2" got="$3"
|
||||
if [[ "$want" == "not3" ]]; then
|
||||
if [[ "$got" -eq 0 || "$got" -eq 3 ]]; then
|
||||
echo "FAIL $name: expected a refusal rc (nonzero, not 3), got $got" >&2
|
||||
failures=$((failures + 1))
|
||||
return 1
|
||||
fi
|
||||
elif [[ "$got" -ne "$want" ]]; then
|
||||
echo "FAIL $name: expected rc=$want, got rc=$got" >&2
|
||||
failures=$((failures + 1))
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
expect_text() {
|
||||
local name="$1" want="$2" output="$3" polarity="${4:-present}"
|
||||
if [[ "$polarity" == "present" && "$output" != *"$want"* ]]; then
|
||||
echo "FAIL $name: output missing '$want'" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$polarity" == "absent" && "$output" == *"$want"* ]]; then
|
||||
echo "FAIL $name: output unexpectedly contains '$want'" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
repo_root_fetched() {
|
||||
grep -q 'repos/acme/widgets$' "$URL_LOG"
|
||||
}
|
||||
|
||||
# (a) merge + no-status + flag + admin -> exit 0, assertion line, JSONL record.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_a=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard a --purpose merge --no-ci-expected 2>&1)
|
||||
rc_a=$?
|
||||
set -u
|
||||
if expect_rc a 0 "$rc_a"; then
|
||||
expect_text a "queue-clear state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_a"
|
||||
expect_text a "ASSERTED_NOT_READY" "$out_a" absent
|
||||
if ! grep -q '"outcome":"NO_CI_ASSERTED"' "$WORK_DIR/audit-a.jsonl" 2>/dev/null; then
|
||||
echo "FAIL a: expected a NO_CI_ASSERTED JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
elif ! grep -q '"asserted_by":"test-identity"' "$WORK_DIR/audit-a.jsonl"; then
|
||||
echo "FAIL a: audit record does not name the asserting identity" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# (b) merge + no-status, no flag -> exit 3, existing error text unchanged.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_b=$(MOSAIC_STUB_STATUS_MODE=no-status run_guard b --purpose merge 2>&1)
|
||||
rc_b=$?
|
||||
set -u
|
||||
if expect_rc b 3 "$rc_b"; then
|
||||
expect_text b "Error: ASSERTED_NOT_READY state=no-status purpose=merge branch=main." "$out_b"
|
||||
expect_text b "asserted-by" "$out_b" absent
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL b: admin endpoint consulted without the flag" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (c) merge + no-status + flag + non-admin -> distinct refusal, rc NOT 3.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard c --purpose merge --no-ci-expected 2>&1)
|
||||
rc_c=$?
|
||||
set -u
|
||||
if expect_rc c not3 "$rc_c"; then
|
||||
if [[ "$rc_c" -ne 77 ]]; then
|
||||
echo "FAIL c: expected the documented refusal rc=77, got $rc_c" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
expect_text c "ASSERTION_REFUSED state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_c"
|
||||
expect_text c "ASSERTED_NOT_READY" "$out_c" absent
|
||||
if ! grep -q '"outcome":"ASSERTION_REFUSED"' "$WORK_DIR/audit-c.jsonl" 2>/dev/null; then
|
||||
echo "FAIL c: expected an ASSERTION_REFUSED JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# (c2) admin payload with no admin field -> fail closed as non-admin.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_c2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=no-admin-field run_guard c2 --purpose merge --no-ci-expected 2>&1)
|
||||
rc_c2=$?
|
||||
set -u
|
||||
if expect_rc c2 77 "$rc_c2"; then
|
||||
expect_text c2 "ASSERTION_REFUSED" "$out_c2"
|
||||
fi
|
||||
|
||||
# (d) flag + --require-status -> usage error before any network I/O.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_d=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard d --purpose merge --no-ci-expected --require-status 2>&1)
|
||||
rc_d=$?
|
||||
set -u
|
||||
if expect_rc d 1 "$rc_d"; then
|
||||
expect_text d "--no-ci-expected and --require-status contradict" "$out_d"
|
||||
fi
|
||||
if [[ -s "$URL_LOG" ]]; then
|
||||
echo "FAIL d: usage error must precede every network call" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (e) flag + a real pending context -> still holds; admin endpoint never asked.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending MOSAIC_STUB_ADMIN_MODE=admin run_guard e --purpose merge --no-ci-expected 2>&1)
|
||||
rc_e=$?
|
||||
set -u
|
||||
if expect_rc e 124 "$rc_e"; then
|
||||
expect_text e "ASSERTED_NOT_READY" "$out_e"
|
||||
expect_text e "ci/woodpecker=running" "$out_e"
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL e: a pending context must not trigger the admin assertion" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (f) push + no-status stays queue-clear, with and without the flag.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_f=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f --purpose push 2>&1)
|
||||
rc_f=$?
|
||||
set -u
|
||||
if expect_rc f 0 "$rc_f"; then
|
||||
expect_text f "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f"
|
||||
fi
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_f2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f2 --purpose push --no-ci-expected 2>&1)
|
||||
rc_f2=$?
|
||||
set -u
|
||||
if expect_rc f2 0 "$rc_f2"; then
|
||||
expect_text f2 "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f2"
|
||||
expect_text f2 "asserted-by" "$out_f2" absent
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL f: push must not consult the admin endpoint" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75), not a pass.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_g=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=unreachable run_guard g --purpose merge --no-ci-expected 2>&1)
|
||||
rc_g=$?
|
||||
set -u
|
||||
if expect_rc g 75 "$rc_g"; then
|
||||
expect_text g "CANNOT_ASSERT reason=repo-permissions-unavailable" "$out_g"
|
||||
fi
|
||||
if ! grep -q '"outcome":"CANNOT_ASSERT"' "$WORK_DIR/audit-g.jsonl" 2>/dev/null; then
|
||||
echo "FAIL g: expected a CANNOT_ASSERT JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (h) flag + admin stub + no asserting identity -> refusal before queue-clear
|
||||
# and before the admin lookup: rc 78, no queue-clear line, an
|
||||
# ASSERTION_UNATTRIBUTABLE JSONL record, and zero repos/ network calls.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard_no_identity h --purpose merge --no-ci-expected 2>&1)
|
||||
rc_h=$?
|
||||
set -u
|
||||
if expect_rc h 78 "$rc_h"; then
|
||||
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
|
||||
expect_text h "queue-clear" "$out_h" absent
|
||||
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
|
||||
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL h: an unattributable caller must not trigger the permission lookup" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait no-ci-expected regression passed (all outcome classes)"
|
||||
@@ -100,6 +100,15 @@ case "$url" in
|
||||
*/commits/*/status)
|
||||
printf '{"state":"success","statuses":[{"context":"ci/mock","status":"success"}]}'
|
||||
;;
|
||||
# Repo roots: the pr-create API fallback resolves its base from the forge
|
||||
# default_branch (T51-P2 WP5a). Exact-suffix matches so the /pulls POST
|
||||
# endpoint (no trailing path) still falls through to the catch-all.
|
||||
*/api/v1/repos/USC/uconnect)
|
||||
printf '{"default_branch":"main"}'
|
||||
;;
|
||||
*/api/v1/repos/mosaicstack/stack)
|
||||
printf '{"default_branch":"next"}'
|
||||
;;
|
||||
*)
|
||||
printf '{}'
|
||||
;;
|
||||
@@ -110,8 +119,20 @@ chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||
|
||||
run_in_repo() {
|
||||
(
|
||||
# HERMETICITY, second half (#1356). The empty repo-local `mosaic.gitIdentity`
|
||||
# above pins the git-config route into identity resolution. It does NOT pin
|
||||
# the environment route, and MOSAIC_GIT_IDENTITY is checked FIRST — so on any
|
||||
# provisioned seat, where the launcher exports it, this suite failed before
|
||||
# any change: rc=1 as-is, rc=0 under `env -u MOSAIC_GIT_IDENTITY`, one
|
||||
# variable. A suite that cannot run on a seat cannot guard this code for the
|
||||
# agents that actually run it.
|
||||
#
|
||||
# Unset rather than set empty: an empty MOSAIC_GIT_IDENTITY and an absent one
|
||||
# take different branches in resolve_git_identity(), and the case under test
|
||||
# is "no identity at all".
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
env -u MOSAIC_GIT_IDENTITY \
|
||||
PATH="${_SANDBOX_BIN:-$BIN_DIR}:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
@@ -307,14 +328,11 @@ SH
|
||||
chmod +x "$BIN_DIR2/tea"
|
||||
|
||||
run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
)
|
||||
# Same sandbox as run_in_repo, different mock tea (BIN_DIR2 defines a
|
||||
# mosaicstack login). This MUST delegate rather than re-implement: it was a
|
||||
# copy once, and the copy silently missed the MOSAIC_GIT_IDENTITY unset, so
|
||||
# the suite kept failing on a seat after run_in_repo was already fixed.
|
||||
_SANDBOX_BIN="$BIN_DIR2" run_in_repo "$@"
|
||||
}
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
@@ -340,6 +358,151 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
|
||||
fi
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #1356: the git-identity ladder. A seat declares who it is (MOSAIC_GIT_IDENTITY
|
||||
# or `git config mosaic.gitIdentity`); resolution must use THAT seat's login and
|
||||
# must REFUSE to borrow another one when it is absent. Silently borrowing
|
||||
# satisfies gate 16 mechanically (a review exists) while violating it (the
|
||||
# reviewer and the author are the same actor under two names).
|
||||
#
|
||||
# BIN_DIR3 mocks a tea that holds a canonical per-seat login, which is what a
|
||||
# projected seat looks like. BIN_DIR2 (mosaicstack only) is reused as the
|
||||
# "seat has no login" case — no third mock needed for the negative branch.
|
||||
# ---------------------------------------------------------------------------
|
||||
BIN_DIR3="$WORK_DIR/bin3"
|
||||
mkdir -p "$BIN_DIR3"
|
||||
cp "$BIN_DIR/curl" "$BIN_DIR3/curl"
|
||||
cat > "$BIN_DIR3/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"ci-bot"},
|
||||
{"name":"mosaicstack-testseat","url":"https://git.mosaicstack.dev","user":"testseat"},
|
||||
{"name":"usc","url":"https://git.uscllc.com","user":"ci-bot"}
|
||||
]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR3/tea"
|
||||
|
||||
run_in_repo3() { _SANDBOX_BIN="$BIN_DIR3" run_in_repo "$@"; }
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
# Branch 1 (host path): identity set, canonical login PRESENT -> that login wins
|
||||
# over the shared `mosaicstack` one, which is what host-matching alone would pick.
|
||||
ladder_hit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
')
|
||||
if [[ "$ladder_hit" != "mosaicstack-testseat" ]]; then
|
||||
echo "Expected identity ladder to select 'mosaicstack-testseat'; got '$ladder_hit'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# CONTROL for branch 1: the same mock, no identity, must still resolve by host.
|
||||
# Without this, branch 1 passing proves nothing about the ladder specifically --
|
||||
# it would also pass if the code just picked the last matching login.
|
||||
ladder_none=$(run_in_repo3 bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
')
|
||||
if [[ "$ladder_none" != "mosaicstack" ]]; then
|
||||
echo "Expected no-identity host resolution to stay 'mosaicstack'; got '$ladder_none'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Branch 2 (host path): identity set, canonical login ABSENT -> fail closed with a
|
||||
# named error. Two assertions, and they are not the same one twice: rc!=0 proves
|
||||
# it refused, and the ABSENCE of any login on stdout proves it did not borrow the
|
||||
# `mosaicstack` login that is sitting right there matching the host.
|
||||
ladder_err=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
' 2>&1 1>/dev/null || true)
|
||||
ladder_out=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
' 2>/dev/null || true)
|
||||
if [[ -n "$ladder_out" ]]; then
|
||||
echo "Identity ladder BORROWED login '$ladder_out' instead of failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "mosaicstack-testseat" <<<"$ladder_err"; then
|
||||
echo "Expected the error to name the login it wanted; got: $ladder_err" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Branch 3: `git config mosaic.gitIdentity` is the second rung and must work when
|
||||
# the environment variable is absent -- a seat may be configured either way.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity testseat
|
||||
ladder_gitcfg=$(run_in_repo3 bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
')
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity || true
|
||||
if [[ "$ladder_gitcfg" != "mosaicstack-testseat" ]]; then
|
||||
echo "Expected git-config identity rung to select 'mosaicstack-testseat'; got '$ladder_gitcfg'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Branch 4 (--repo override path): same rule, owner-derived instead of host-derived.
|
||||
override_ladder=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_repo_override mosaicstack/stack
|
||||
')
|
||||
if [[ "$override_ladder" != "mosaicstack-testseat" ]]; then
|
||||
echo "Expected --repo override ladder to select 'mosaicstack-testseat'; got '$override_ladder'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Branch 5: explicit GITEA_LOGIN outranks the ladder. An operator naming a login
|
||||
# by hand is a deliberate act, not an accident to be second-guessed.
|
||||
override_explicit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat GITEA_LOGIN=mosaicstack bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_repo_override mosaicstack/stack
|
||||
')
|
||||
if [[ "$override_explicit" != "mosaicstack" ]]; then
|
||||
echo "Expected explicit GITEA_LOGIN to outrank the identity ladder; got '$override_explicit'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Branch 6 (#1357 S1): with tea ABSENT from PATH, the override path must say tea is
|
||||
# missing, not "no tea login named X exists" (a cause that was never checked) and
|
||||
# not the seat-logins.sh advice, which cannot be followed without tea.
|
||||
NOTEA_BIN="$WORK_DIR/notea-bin"; mkdir -p "$NOTEA_BIN"
|
||||
for t in bash git python3 sed grep cat mktemp dirname basename readlink env sort head tr cut; do
|
||||
_p="$(command -v "$t" 2>/dev/null || true)"; [[ -n "$_p" ]] && ln -sf "$_p" "$NOTEA_BIN/$t"
|
||||
done
|
||||
override_notea_rc=0
|
||||
override_notea_err=$(cd "$REPO_DIR" && env -u GITEA_LOGIN \
|
||||
PATH="$NOTEA_BIN" HOME="$HOME_DIR" MOSAIC_GIT_IDENTITY=testseat \
|
||||
bash -c '
|
||||
command -v tea >/dev/null 2>&1 && { echo "SETUP: tea still on PATH"; exit 99; }
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_repo_override mosaicstack/stack
|
||||
' 2>&1 >/dev/null) || override_notea_rc=$?
|
||||
if [[ "$override_notea_rc" != 1 ]]; then
|
||||
echo "Expected --repo override path to fail (rc=1) with tea absent; got rc=$override_notea_rc: $override_notea_err" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea is not installed' <<<"$override_notea_err"; then
|
||||
echo "Expected --repo override path to name tea as absent; got: $override_notea_err" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'has no tea login\|seat-logins.sh' <<<"$override_notea_err"; then
|
||||
echo "Override path diagnosed a missing LOGIN while tea itself is absent: $override_notea_err" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
|
||||
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
|
||||
|
||||
@@ -76,7 +76,22 @@ exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
}
|
||||
# #1356: login resolution is now identity-aware, so the tea-branch fixture must
|
||||
# offer the login the RUNNER's identity resolves to; otherwise every case below
|
||||
# fails closed before reaching the branch under test.
|
||||
#
|
||||
# This does NOT make the suite hermetic, and it is not trying to. The API-path
|
||||
# cases (5-7) need a usable Gitea token, and with an identity set the token path
|
||||
# reads that seat's credential file rather than the GITEA_TOKEN exported above.
|
||||
# So this suite passes only where the runner owns a real credential for its own
|
||||
# identity, and fails with no identity at all -- on this branch and on its base
|
||||
# alike. That is a pre-existing hole in the fixture, filed separately; pinning a
|
||||
# synthetic identity here would only convert it into a confident-looking green.
|
||||
_LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}"
|
||||
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
if [[ -n "$_LOGIN_IDENT" ]]; then
|
||||
LOGIN_JSON='[{"name":"mosaicstack-'"$_LOGIN_IDENT"'","url":"https://git.mosaicstack.dev"},{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
fi
|
||||
|
||||
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||
# to the real tea/curl and the "test" mutates the real provider.
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# HERMETICITY (#1356): this suite's subject is body quoting, not identity. An
|
||||
# ambient MOSAIC_GIT_IDENTITY (every provisioned seat exports one) would make the
|
||||
# identity ladder demand a per-seat login this fixture does not define, and the
|
||||
# suite would fail for a reason it is not testing.
|
||||
unset MOSAIC_GIT_IDENTITY
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
|
||||
@@ -67,7 +67,18 @@ cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
printf '%s\n' '{"number":703}'
|
||||
# Repo roots: the pr-create API fallback resolves its base from the forge
|
||||
# default_branch (T51-P2 WP5a). Exact-suffix so every other endpoint keeps
|
||||
# the historical answer below.
|
||||
url="${*: -1}"
|
||||
case "$url" in
|
||||
*/api/v1/repos/mosaicstack/stack)
|
||||
printf '%s\n' '{"default_branch":"next"}'
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' '{"number":703}'
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression: issue-view.sh must show comment BODIES, on both paths, and must name
|
||||
# the failure tea actually reported instead of guessing a credential cause (#1357).
|
||||
#
|
||||
# Four defects, each with its own case below:
|
||||
# F1 tea exits 1 in any repo with extensions.worktreeconfig=true; the wrapper must
|
||||
# say so (git-config condition) and fall back to the API.
|
||||
# F2 the API fallback dumped raw issue JSON, which carries only a comment COUNT.
|
||||
# F3 the tea path never passed --comments, so tea prompted (non-interactively: nothing).
|
||||
# F4 on ANY tea failure the wrapper printed the REVOKED OR STALE TOKEN note.
|
||||
#
|
||||
# Verification bar (plan §6): assert a real comment BODY appears, not a count and not
|
||||
# `grep -c comment` (that instrument matched the issue title and read inverted).
|
||||
#
|
||||
# Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials.
|
||||
set -euo pipefail
|
||||
|
||||
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||
SANDBOX="$WORK_ROOT/issue-view-comments-test-$$"
|
||||
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
||||
cleanup() { rm -rf "$SANDBOX"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET="$SCRIPT_DIR/issue-view.sh"
|
||||
[ -f "$TARGET" ] || { echo "FAIL: issue-view.sh not found beside this test"; exit 1; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||
git init -q || fail "setup: git init failed"
|
||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||
export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
# The identity ladder must not reach for this seat's real login; the mock tea below
|
||||
# defines the only login that exists in this sandbox.
|
||||
unset MOSAIC_GIT_IDENTITY
|
||||
# No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an
|
||||
# identity-less caller (by design), which would make this test measure the host, not
|
||||
# the wrapper. An empty brain home makes the sandbox the same on every host.
|
||||
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
|
||||
mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home"
|
||||
|
||||
# Distinctive strings: a comment body that appears nowhere else, and an issue title
|
||||
# that contains the word "comment" so a count-of-the-word instrument would misread.
|
||||
BODY_MARKER="zebra-quill-comment-body-7731"
|
||||
ISSUE_TITLE="wrapper never shows a comment"
|
||||
|
||||
# --- mock curl: serves the issue and its comments; logs every call --------------
|
||||
cat > "$MOCK_BIN/curl" <<EOF
|
||||
#!/bin/bash
|
||||
url=""
|
||||
while [ \$# -gt 0 ]; do
|
||||
case "\$1" in
|
||||
http*) url="\$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf 'curl %s\n' "\$url" >> "$CALLS"
|
||||
case "\$url" in
|
||||
*/issues/77/comments)
|
||||
if [ "\${MOCK_NO_COMMENTS:-}" = "1" ]; then echo '[]'; else
|
||||
echo '[{"id":1,"user":{"login":"alice"},"created_at":"2026-08-21T00:00:00Z","body":"$BODY_MARKER"}]'; fi ;;
|
||||
*/issues/77)
|
||||
echo '{"number":77,"title":"$ISSUE_TITLE","state":"open","user":{"login":"bob"},"created_at":"2026-08-21T00:00:00Z","labels":[],"milestone":null,"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/issues/77","body":"issue body","comments":1}' ;;
|
||||
*) echo '{}' ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/curl"
|
||||
|
||||
# --- mock tea: MOCK_TEA_MODE selects the behaviour under test --------------------
|
||||
# ok : prints the issue, and the comment body ONLY when --comments is passed (F3)
|
||||
# wtconfig : exits 1 with the repositoryformatversion error (F1/F4)
|
||||
# badtoken : exits 1 with tea's credential error (F4 control: credential wording allowed)
|
||||
cat > "$MOCK_BIN/tea" <<EOF
|
||||
#!/bin/bash
|
||||
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||
if [[ "\$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'; exit 0
|
||||
fi
|
||||
case "\${MOCK_TEA_MODE:-ok}" in
|
||||
wtconfig) echo 'Error: core.repositoryformatversion does not support extension: worktreeconfig' >&2; exit 1 ;;
|
||||
badtoken) echo 'Failed to create Gitea client: invalid username, password or token' >&2; exit 1 ;;
|
||||
esac
|
||||
echo "# #77 $ISSUE_TITLE (open)"
|
||||
echo "issue body"
|
||||
if [[ "\$*" == *"--comments"* ]]; then echo "$BODY_MARKER"; fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
|
||||
[ "$(command -v tea)" = "$MOCK_BIN/tea" ] || fail "setup: tea does not resolve inside the sandbox"
|
||||
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve inside the sandbox"
|
||||
|
||||
run() { bash "$TARGET" -i 77 >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
|
||||
|
||||
# F3: tea path shows the comment body, which the mock emits only under --comments.
|
||||
: > "$CALLS"
|
||||
rc=$(MOCK_TEA_MODE=ok run)
|
||||
[ "$rc" = 0 ] || fail "F3: expected rc=0 on the tea path, got $rc: $(cat "$SANDBOX/err")"
|
||||
grep -q -- '--comments' "$CALLS" || fail "F3: tea was not invoked with --comments: $(cat "$CALLS")"
|
||||
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F3: comment body missing from tea-path output"
|
||||
if grep -q '^curl' "$CALLS"; then fail "F3: tea path succeeded but the API fallback ran anyway"; fi
|
||||
|
||||
# F1 + F2: worktreeconfig failure is named as a git-config condition, falls back to
|
||||
# the API, and the API rendering includes the comment BODY.
|
||||
: > "$CALLS"
|
||||
rc=$(MOCK_TEA_MODE=wtconfig run)
|
||||
[ "$rc" = 0 ] || fail "F1: expected rc=0 via API fallback, got $rc: $(cat "$SANDBOX/err")"
|
||||
grep -q 'worktreeconfig' "$SANDBOX/err" || fail "F1: stderr does not name the worktreeconfig cause: $(cat "$SANDBOX/err")"
|
||||
grep -q 'not a credential problem' "$SANDBOX/err" || fail "F1: stderr does not rule out the credential cause"
|
||||
grep -q 'issues/77/comments' "$CALLS" || fail "F2: API fallback never fetched /comments: $(cat "$CALLS")"
|
||||
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F2: comment body missing from API-path output"
|
||||
grep -q "$ISSUE_TITLE" "$SANDBOX/out" || fail "F2: issue title missing from API-path output"
|
||||
if grep -q 'REVOKED OR STALE' "$SANDBOX/err"; then fail "F4: stale-token note printed for a git-config failure"; fi
|
||||
if grep -q '"comments": 1' "$SANDBOX/out"; then fail "F2: output is still raw JSON (comment count instead of bodies)"; fi
|
||||
|
||||
# F4 control: a real credential error from tea may still carry the credential note,
|
||||
# and tea's own line must be relayed so the reader sees the actual cause.
|
||||
: > "$CALLS"
|
||||
rc=$(MOCK_TEA_MODE=badtoken run)
|
||||
[ "$rc" = 0 ] || fail "F4 control: expected rc=0 via API fallback, got $rc"
|
||||
grep -q 'invalid username, password or token' "$SANDBOX/err" || fail "F4: tea's own error line was not relayed"
|
||||
if grep -q 'worktreeconfig' "$SANDBOX/err"; then fail "F4: git-config wording printed for a credential failure"; fi
|
||||
|
||||
# Negative control: an issue with no comments prints no comment section on the API
|
||||
# path. Without this, a renderer that always prints a section would pass F2.
|
||||
: > "$CALLS"
|
||||
rc=$(MOCK_TEA_MODE=wtconfig MOCK_NO_COMMENTS=1 run)
|
||||
[ "$rc" = 0 ] || fail "negative control: expected rc=0, got $rc"
|
||||
if grep -q -- '--- Comments' "$SANDBOX/out"; then fail "negative control: comment section printed for an issue with no comments"; fi
|
||||
if grep -q "$BODY_MARKER" "$SANDBOX/out"; then fail "negative control: a comment body appeared for an issue with no comments"; fi
|
||||
|
||||
echo "issue-view comments regression harness passed"
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-pr-create-fallback-default-base.sh — hermetic test for the API-fallback
|
||||
# base resolution in pr-create.sh (T51-P2 WP5a / spec E4).
|
||||
#
|
||||
# The API-fallback payload historically hardcoded "base": "main", mistargeting
|
||||
# every fallback PR on repos whose trunk is not main (e.g. mosaicstack/stack,
|
||||
# default branch "next"). The fix: an explicit -B always wins; with none, the
|
||||
# base is resolved from the provider API default_branch, and a failed
|
||||
# resolution fails loud instead of guessing.
|
||||
#
|
||||
# Hermetic by construction: every curl invocation is a PATH-first stub; the
|
||||
# fixture repo's remote is git.example.test (never dialed); HOME is a sandbox
|
||||
# with no tea config (so the wrapper takes the API fallback path); GITEA_TOKEN
|
||||
# comes from the environment. No real forge is contacted.
|
||||
|
||||
# shellcheck disable=SC2317
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-fallback-base}"
|
||||
|
||||
PASS=0 FAIL=0 FAILED_CASES=""
|
||||
|
||||
ok() { PASS=$((PASS + 1)); }
|
||||
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
|
||||
|
||||
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
|
||||
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
|
||||
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
|
||||
|
||||
json_field() { # $1 payload file, $2 field
|
||||
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$1" "$2"
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
|
||||
# ---- fixture -----------------------------------------------------------------
|
||||
ROOT="$WORK_DIR/fixture"
|
||||
TOOLS="$ROOT/tools/git"
|
||||
mkdir -p "$TOOLS" "$ROOT/repo" "$ROOT/home" "$ROOT/stub"
|
||||
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS/pr-create.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS/detect-platform.sh"
|
||||
|
||||
git -C "$ROOT/repo" init -q -b fix/e4
|
||||
git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base
|
||||
git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
# curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls ->
|
||||
# capture payload, answer with a minimal PR JSON. Every call is logged.
|
||||
cat > "$ROOT/stub/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
mode="\${CURL_STUB_GET_MODE:-ok}"
|
||||
printf '%s\n' "\$*" >> "$ROOT/curl-calls.log"
|
||||
url="\${!#}"
|
||||
if [[ "\$url" == */api/v1/repos/acme/widgets ]]; then
|
||||
# repo GET (default-branch resolution). Modes cover the value shapes the
|
||||
# resolver must accept or refuse (T51P2WP5AR B2/B3).
|
||||
case "\$mode" in
|
||||
ok) printf '%s\n' '{"id":1,"default_branch":"next","full_name":"acme/widgets"}' ;;
|
||||
fail) echo "curl stub: simulated repo lookup failure" >&2; exit 1 ;;
|
||||
fail-json) printf '%s\n' '{"default_branch":"next"}'; exit 22 ;;
|
||||
null) printf '%s\n' '{"default_branch":null}' ;;
|
||||
numeric) printf '%s\n' '{"default_branch":7}' ;;
|
||||
blank) printf '%s\n' '{"default_branch":" "}' ;;
|
||||
*) echo "curl stub: unknown GET mode \$mode" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\$url" == */api/v1/repos/acme/widgets/pulls ]]; then
|
||||
# PR POST: capture the payload, emit a PR-shaped answer
|
||||
while [[ \$# -gt 0 ]]; do
|
||||
case "\$1" in
|
||||
-d) printf '%s' "\$2" > "$ROOT/payload.json"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' '{"number":42,"html_url":"https://git.example.test/acme/widgets/pulls/42"}'
|
||||
exit 0
|
||||
fi
|
||||
echo "curl stub: unexpected URL \$url" >&2
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$ROOT/stub/curl"
|
||||
|
||||
run_pr_create() { # args... -> sets RC/OUT/ERR
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
}
|
||||
|
||||
calls_matching() { grep -c -- "$1" "$ROOT/curl-calls.log" 2>/dev/null || true; }
|
||||
|
||||
echo "== (1) no -B: fallback base resolves to the forge default branch, not main =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
run_pr_create -t "fix thing"
|
||||
assert_rc "rc" 0 "$RC"
|
||||
assert_contains "API fallback path taken (tea login unresolvable in fixture)" "$ERR" "trying Gitea API fallback"
|
||||
assert_eq "repo GET performed" 1 "$(calls_matching '/api/v1/repos/acme/widgets$')"
|
||||
assert_eq "POST performed" 1 "$(calls_matching '/pulls$')"
|
||||
assert_eq "payload base is forge default (next)" "next" "$(json_field "$ROOT/payload.json" base)"
|
||||
assert_eq "payload head" "fix/e4" "$(json_field "$ROOT/payload.json" head)"
|
||||
assert_eq "payload title" "fix thing" "$(json_field "$ROOT/payload.json" title)"
|
||||
|
||||
echo "== (2) explicit -B wins; the default branch is not consulted =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
run_pr_create -t "fix thing" -B release/1.x
|
||||
assert_rc "rc" 0 "$RC"
|
||||
assert_eq "repo GET not consulted for explicit base" 0 "$(calls_matching '/api/v1/repos/acme/widgets$')"
|
||||
assert_eq "payload base is the explicit -B" "release/1.x" "$(json_field "$ROOT/payload.json" base)"
|
||||
|
||||
echo "== (3) default-branch lookup failure: loud refusal, no POST =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
CURL_STUB_GET_MODE=fail \
|
||||
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
assert_rc "nonzero rc on unresolvable base" 1 "$RC"
|
||||
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
|
||||
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
|
||||
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
|
||||
|
||||
echo "== (4) payload never contains the literal fallback main =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
run_pr_create -t "fix thing"
|
||||
assert_rc "rc" 0 "$RC"
|
||||
assert_eq "base field is next, never main" "next" "$(json_field "$ROOT/payload.json" base)"
|
||||
|
||||
echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resolution =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
CURL_STUB_GET_MODE=fail-json \
|
||||
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
assert_rc "nonzero rc on HTTP failure despite valid JSON" 1 "$RC"
|
||||
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
|
||||
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
|
||||
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
|
||||
|
||||
echo "== (6) B3: null / numeric / blank default_branch are failed resolutions =="
|
||||
for bad in null numeric blank; do
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
CURL_STUB_GET_MODE="$bad" \
|
||||
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
assert_rc "B3 $bad: nonzero rc" 1 "$RC"
|
||||
assert_contains "B3 $bad: loud error" "$ERR" "could not resolve the forge default branch"
|
||||
assert_eq "B3 $bad: no POST issued" 0 "$(calls_matching '/pulls$')"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "FAILED CASES:$FAILED_CASES"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL GREEN"
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-merge must forward --no-ci-expected to the queue guard, and only then.
|
||||
# The flag is the sanctioned merge path for a repository with no CI configured
|
||||
# (see test-ci-queue-wait-no-ci-expected.sh for the guard-side semantics);
|
||||
# this harness pins only the pass-through: present when requested, absent when
|
||||
# not, with the rest of the guard invocation unchanged.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-no-ci-expected}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
run_merge() {
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123 "$@"
|
||||
) >/dev/null 2>&1
|
||||
}
|
||||
|
||||
fail=0
|
||||
|
||||
# With the flag: it must reach the guard invocation.
|
||||
: > "$CALL_LOG"
|
||||
set +e
|
||||
run_merge --no-ci-expected
|
||||
rc_with=$?
|
||||
set -e
|
||||
if [[ "$rc_with" -ne 42 ]]; then
|
||||
echo "FAIL(with): expected queue stub rc=42 to propagate, got $rc_with" >&2
|
||||
fail=1
|
||||
elif ! grep -q -- '--no-ci-expected' "$CALL_LOG"; then
|
||||
echo "FAIL(with): --no-ci-expected did not reach the queue guard" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
fail=1
|
||||
fi
|
||||
# The rest of the guard invocation is unchanged by the flag.
|
||||
for required in '--purpose merge' '-B fix/no-ci-fixture' '-R mosaicstack/stack' \
|
||||
'--sha 0123456789abcdef0123456789abcdef01234567'; do
|
||||
if ! grep -qF -- "$required" "$CALL_LOG"; then
|
||||
echo "FAIL(with): guard invocation lost '$required'" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Without the flag: it must NOT appear in the guard invocation.
|
||||
: > "$CALL_LOG"
|
||||
set +e
|
||||
run_merge
|
||||
rc_without=$?
|
||||
set -e
|
||||
if [[ "$rc_without" -ne 42 ]]; then
|
||||
echo "FAIL(without): expected queue stub rc=42 to propagate, got $rc_without" >&2
|
||||
fail=1
|
||||
elif grep -q -- '--no-ci-expected' "$CALL_LOG"; then
|
||||
echo "FAIL(without): --no-ci-expected reached the guard without being requested" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "pr-merge no-ci-expected pass-through regression passed"
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -13,7 +13,6 @@
|
||||
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
|
||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input prompt could not be located to confirm the message actually landed.
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
@@ -97,10 +99,50 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
[ "$bottom" -gt "$top" ] || return 1
|
||||
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||
# empty, which is the delivered shape.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||
# REPL input box located AND clear of our message tail. The historical bug was
|
||||
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
|
||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
@@ -113,15 +155,14 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
status="queued"; break
|
||||
fi
|
||||
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
|
||||
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
|
||||
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
if [ -z "$promptline" ]; then
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED and retry; never infer delivery.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
status="unconfirmed"; continue
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
|
||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
@@ -135,6 +176,6 @@ case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
@@ -69,6 +76,56 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n\n'
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -61,6 +61,8 @@ export const STAGES = [
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user