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>
165 lines
5.5 KiB
Bash
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 ]
|