claude-forgejo-sandbox/scripts/reforge-smoke.sh
Padreug df0fd9a9ba feat: extract reforge engine into a standalone consumable flake
The forgejo-sandbox / reforge harness, lifted out of the machine config
into a host-agnostic, generic engine anyone can consume with Nix.

Two layers:
- engine (this repo) — nixosModules.reforge stands up the sandbox forge,
  provisions role accounts + tokens, enforces branch protection, and puts
  the reforge-* CLI + forgejo-mcp on PATH. Carries no project specifics.
- run config — per-project manifest/charter/agenda/issues an adopter fills
  in; scaffold one with the `reforge` flake template.

Portability fixes vs the in-config version:
- forgejo-mcp resolved from $REFORGE_MCP_BIN or PATH, never a named host
  (kills the nixosConfigurations.omni hardcode).
- all instance data + paths parameterized via REFORGE_* env, baked into the
  reforge-scripts wrappers from module options (configDir, agentsDir,
  refsDir, org, port, tokenOwner, ...).
- option namespace neutral (reforge.* not omni.packs.*); settings policies
  carry no absolute /etc/nixos paths.
- role briefs + orchestrator playbook genericized: all project specifics
  point at the charter; refs corpus optional.

Validated: nix flake check (eval) + builds of forgejo-mcp, reforge-scripts,
and a module-eval check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:51:47 +02:00

165 lines
5.5 KiB
Bash

#!/usr/bin/env bash
# End-to-end smoke test of the sandbox forge toolchain (docs/architecture.md).
# Run after every reset, before starting a run: exercises the full issue ->
# branch -> PR -> review-gated merge flow against the working repo with the
# provisioned role accounts, asserting both the happy path AND the gates:
#
# 1. backend-dev files an issue
# 2. backend-dev pushes a feature branch
# 3. backend-dev opens a PR referencing the issue
# 4. merging WITHOUT approval is BLOCKED (branch protection)
# 5. direct push to main is REJECTED (branch protection)
# 6. reviewer leaves a comment review
# 7. security-lead approves
# 8. merge after approval succeeds
# 9. main contains the change
#
# Leaves the issue/PR in the working repo as a record; exits non-zero if
# any step fails.
#
# reforge-smoke
set -euo pipefail
FORGE_URL=${REFORGE_FORGE_URL:-http://localhost:3030}
ORG=${REFORGE_ORG:-sandbox-team}
REPO=${REFORGE_REPO_NAME:-sandbox-project}
TOKENS_DIR=${REFORGE_TOKENS_DIR:-/var/lib/forgejo-sandbox/tokens}
API="$FORGE_URL/api/v1"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
RESP="$WORK/resp"
STAMP=$(date +%s)
tok() { cat "$TOKENS_DIR/$1.token"; }
api() { # user method path [json-body] -> echoes HTTP code, body in $RESP
local user=$1 method=$2 path=$3 data=${4:-}
local args=(
-sS -o "$RESP" -w '%{http_code}' -X "$method"
-H "Authorization: token $(tok "$user")"
-H 'Content-Type: application/json'
)
if [ -n "$data" ]; then args+=(--data "$data"); fi
curl "${args[@]}" "$API$path"
}
pass=0 fail=0
PASS() {
echo " PASS $*"
pass=$((pass + 1))
}
FAIL() {
echo " FAIL $*"
fail=$((fail + 1))
}
die() {
FAIL "$*"
cat "$RESP" >&2 || true
exit 1
}
# ── wait for the API ────────────────────────────────────────────────
ready=
for _ in $(seq 1 30); do
if curl -sf "$FORGE_URL/api/healthz" >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
[ "$ready" = 1 ] || {
echo "smoke: forge at $FORGE_URL is not healthy" >&2
exit 1
}
echo "== 1. backend-dev files an issue"
code=$(api backend-dev POST "/repos/$ORG/$REPO/issues" \
"{\"title\":\"smoke $STAMP: verify PR flow\",\"body\":\"Tooling smoke test — issue/branch/PR/review/merge round-trip.\"}")
[ "$code" = 201 ] || die "issue create (HTTP $code)"
issue=$(jq .number <"$RESP")
PASS "issue #$issue created"
echo "== 2. backend-dev pushes a feature branch"
clone_url="http://backend-dev:$(tok backend-dev)@${FORGE_URL#http://}/$ORG/$REPO.git"
git clone --quiet "$clone_url" "$WORK/repo"
git -C "$WORK/repo" checkout --quiet -b "smoke/$STAMP"
echo "smoke $STAMP" >"$WORK/repo/smoke-$STAMP.txt"
git -C "$WORK/repo" add "smoke-$STAMP.txt"
git -C "$WORK/repo" -c user.name=backend-dev -c user.email=backend-dev@sandbox.invalid \
commit --quiet -m "smoke: add smoke-$STAMP.txt (#$issue)"
if git -C "$WORK/repo" push --quiet origin "smoke/$STAMP"; then
PASS "branch smoke/$STAMP pushed"
else
FAIL "branch push"
exit 1
fi
echo "== 3. backend-dev opens a PR"
code=$(api backend-dev POST "/repos/$ORG/$REPO/pulls" \
"{\"title\":\"smoke $STAMP: PR flow\",\"head\":\"smoke/$STAMP\",\"base\":\"main\",\"body\":\"Closes #$issue\"}")
[ "$code" = 201 ] || die "PR create (HTTP $code)"
pr=$(jq .number <"$RESP")
PASS "PR #$pr opened"
echo "== 4. merge WITHOUT approval must be blocked"
code=$(api backend-dev POST "/repos/$ORG/$REPO/pulls/$pr/merge" '{"Do":"merge"}')
case $code in
200) FAIL "unapproved merge WENT THROUGH — branch protection is not active" ;;
*) PASS "unapproved merge blocked (HTTP $code)" ;;
esac
echo "== 5. direct push to main must be rejected"
git -C "$WORK/repo" checkout --quiet main
echo "direct $STAMP" >"$WORK/repo/direct-$STAMP.txt"
git -C "$WORK/repo" add "direct-$STAMP.txt"
git -C "$WORK/repo" -c user.name=backend-dev -c user.email=backend-dev@sandbox.invalid \
commit --quiet -m "smoke: direct push probe"
if git -C "$WORK/repo" push --quiet origin main 2>/dev/null; then
FAIL "direct push to main was ACCEPTED — branch protection is not active"
else
PASS "direct push to main rejected"
fi
echo "== 6. reviewer leaves a comment review"
code=$(api reviewer POST "/repos/$ORG/$REPO/pulls/$pr/reviews" \
'{"event":"COMMENT","body":"smoke: quality/alignment lens present (comment review)."}')
if [ "$code" = 200 ] || [ "$code" = 201 ]; then
PASS "reviewer comment review posted"
else
FAIL "reviewer review (HTTP $code)"
fi
echo "== 7. security-lead approves"
code=$(api security-lead POST "/repos/$ORG/$REPO/pulls/$pr/reviews" \
'{"event":"APPROVED","body":"smoke: approved by the security gate."}')
if [ "$code" = 200 ] || [ "$code" = 201 ]; then
PASS "security-lead approval posted"
else
die "security-lead approval (HTTP $code)"
fi
echo "== 8. merge after approval"
code=$(api backend-dev POST "/repos/$ORG/$REPO/pulls/$pr/merge" '{"Do":"merge"}')
if [ "$code" = 200 ]; then
PASS "PR #$pr merged"
else
die "approved merge (HTTP $code)"
fi
echo "== 9. main contains the change"
git -C "$WORK/repo" fetch --quiet origin main
if git -C "$WORK/repo" cat-file -e "FETCH_HEAD:smoke-$STAMP.txt" 2>/dev/null; then
PASS "smoke-$STAMP.txt present on main"
else
FAIL "merged file missing from main"
fi
# Soft check — informational, not a gate: "Closes #N" should have
# auto-closed the issue on merge to the default branch.
api backend-dev GET "/repos/$ORG/$REPO/issues/$issue" >/dev/null
echo " INFO issue #$issue state after merge: $(jq -r .state <"$RESP")"
echo
echo "smoke: $pass passed, $fail failed."
[ "$fail" = 0 ]