fix: feat: restore smoke-init CI pipeline using mock Forgejo (#124) #126
4 changed files with 194 additions and 57 deletions
36
.woodpecker/smoke-init.yml
Normal file
36
.woodpecker/smoke-init.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# .woodpecker/smoke-init.yml — Smoke test for disinto init using mock Forgejo
|
||||
#
|
||||
# Runs on PRs that touch init-related files:
|
||||
# - bin/disinto (the init code)
|
||||
# - lib/load-project.sh, lib/env.sh (init dependencies)
|
||||
# - tests/** (test changes)
|
||||
# - .woodpecker/smoke-init.yml (pipeline changes)
|
||||
#
|
||||
# Uses mock Forgejo server (starts in <1s) instead of real Forgejo.
|
||||
# Total runtime target: <10 seconds.
|
||||
#
|
||||
# Pipeline steps:
|
||||
# 1. Start mock-forgejo.py (instant startup)
|
||||
# 2. Run smoke-init.sh which:
|
||||
# - Runs disinto init against mock
|
||||
# - Verifies users, repos, labels created via API
|
||||
# - Verifies .env tokens, TOML generated, cron installed
|
||||
# - Queries /mock/state endpoint to verify all API calls made
|
||||
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
- "bin/disinto"
|
||||
- "lib/load-project.sh"
|
||||
- "lib/env.sh"
|
||||
- "tests/**"
|
||||
- ".woodpecker/smoke-init.yml"
|
||||
|
||||
steps:
|
||||
- name: smoke-init
|
||||
image: python:3-alpine
|
||||
commands:
|
||||
- apk add --no-cache bash curl jq git coreutils
|
||||
- python3 tests/mock-forgejo.py &
|
||||
- sleep 1 # wait for mock to start
|
||||
- bash tests/smoke-init.sh
|
||||
BIN
tests/__pycache__/mock-forgejo.cpython-311.pyc
Normal file
BIN
tests/__pycache__/mock-forgejo.cpython-311.pyc
Normal file
Binary file not shown.
|
|
@ -135,6 +135,7 @@ class ForgejoHandler(BaseHTTPRequestHandler):
|
|||
# Users patterns
|
||||
(r"^users/([^/]+)$", f"handle_{method}_users_username"),
|
||||
(r"^users/([^/]+)/tokens$", f"handle_{method}_users_username_tokens"),
|
||||
(r"^users/([^/]+)/repos$", f"handle_{method}_users_username_repos"),
|
||||
# Repos patterns
|
||||
(r"^repos/([^/]+)/([^/]+)$", f"handle_{method}_repos_owner_repo"),
|
||||
(r"^repos/([^/]+)/([^/]+)/labels$", f"handle_{method}_repos_owner_repo_labels"),
|
||||
|
|
@ -150,6 +151,9 @@ class ForgejoHandler(BaseHTTPRequestHandler):
|
|||
(r"^admin/users/([^/]+)$", f"handle_{method}_admin_users_username"),
|
||||
# Org patterns
|
||||
(r"^orgs$", f"handle_{method}_orgs"),
|
||||
# Mock debug endpoints
|
||||
(r"^mock/state$", f"handle_{method}_mock_state"),
|
||||
(r"^mock/shutdown$", f"handle_{method}_mock_shutdown"),
|
||||
]
|
||||
|
||||
for pattern, handler_name in patterns:
|
||||
|
|
@ -237,9 +241,30 @@ class ForgejoHandler(BaseHTTPRequestHandler):
|
|||
SHUTDOWN_REQUESTED = True
|
||||
json_response(self, 200, {"status": "shutdown"})
|
||||
|
||||
def handle_GET_mock_state(self, query):
|
||||
"""GET /mock/state — debug endpoint for smoke tests"""
|
||||
require_token(self)
|
||||
json_response(self, 200, {
|
||||
"users": list(state["users"].keys()),
|
||||
"tokens": list(state["tokens"].keys()),
|
||||
"repos": list(state["repos"].keys()),
|
||||
"orgs": list(state["orgs"].keys()),
|
||||
"labels": {k: [l["name"] for l in v] for k, v in state["labels"].items()},
|
||||
"collaborators": {k: list(v) for k, v in state["collaborators"].items()},
|
||||
"protections": {k: list(v) for k, v in state["protections"].items()},
|
||||
"oauth2_apps": [a["name"] for a in state["oauth2_apps"]],
|
||||
})
|
||||
|
||||
def handle_POST_admin_users(self, query):
|
||||
"""POST /api/v1/admin/users"""
|
||||
require_token(self)
|
||||
# Allow initial admin creation without auth (bootstrap)
|
||||
# After first user exists, require token auth
|
||||
if not state["users"]:
|
||||
# First user creation - bootstrap mode, no auth required
|
||||
pass
|
||||
elif not require_token(self):
|
||||
json_response(self, 401, {"message": "invalid authentication"})
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
|
|
@ -272,10 +297,22 @@ class ForgejoHandler(BaseHTTPRequestHandler):
|
|||
|
||||
def handle_POST_users_username_tokens(self, query):
|
||||
"""POST /api/v1/users/{username}/tokens"""
|
||||
username = require_basic_auth(self)
|
||||
if not username:
|
||||
# Extract username from basic auth header (don't verify password for mock)
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Basic "):
|
||||
json_response(self, 401, {"message": "invalid authentication"})
|
||||
return
|
||||
try:
|
||||
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
|
||||
username, _ = decoded.split(":", 1)
|
||||
except Exception:
|
||||
json_response(self, 401, {"message": "invalid authentication"})
|
||||
return
|
||||
|
||||
# Check user exists in state (don't verify password in mock)
|
||||
if username not in state["users"]:
|
||||
json_response(self, 401, {"message": "user not found"})
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
|
|
@ -424,6 +461,52 @@ class ForgejoHandler(BaseHTTPRequestHandler):
|
|||
state["repos"][key] = repo
|
||||
json_response(self, 201, repo)
|
||||
|
||||
def handle_POST_users_username_repos(self, query):
|
||||
"""POST /api/v1/users/{username}/repos"""
|
||||
require_token(self)
|
||||
|
||||
parts = self.path.split("/")
|
||||
if len(parts) >= 6:
|
||||
username = parts[4]
|
||||
else:
|
||||
json_response(self, 404, {"message": "user not found"})
|
||||
return
|
||||
|
||||
if username not in state["users"]:
|
||||
json_response(self, 404, {"message": "user not found"})
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body) if body else {}
|
||||
|
||||
repo_name = data.get("name")
|
||||
if not repo_name:
|
||||
json_response(self, 400, {"message": "name is required"})
|
||||
return
|
||||
|
||||
repo_id = next_ids["repos"]
|
||||
next_ids["repos"] += 1
|
||||
|
||||
key = f"{username}/{repo_name}"
|
||||
repo = {
|
||||
"id": repo_id,
|
||||
"full_name": key,
|
||||
"name": repo_name,
|
||||
"owner": {"id": state["users"][username].get("id", 0), "login": username},
|
||||
"empty": False,
|
||||
"default_branch": data.get("default_branch", "main"),
|
||||
"description": data.get("description", ""),
|
||||
"private": data.get("private", False),
|
||||
"html_url": f"https://example.com/{key}",
|
||||
"ssh_url": f"git@example.com:{key}.git",
|
||||
"clone_url": f"https://example.com/{key}.git",
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
}
|
||||
|
||||
state["repos"][key] = repo
|
||||
json_response(self, 201, repo)
|
||||
|
||||
def handle_POST_repos_owner_repo_labels(self, query):
|
||||
"""POST /api/v1/repos/{owner}/{repo}/labels"""
|
||||
require_token(self)
|
||||
|
|
@ -537,9 +620,10 @@ class ForgejoHandler(BaseHTTPRequestHandler):
|
|||
|
||||
def handle_PATCH_admin_users_username(self, query):
|
||||
"""PATCH /api/v1/admin/users/{username}"""
|
||||
# Allow unauthenticated PATCH for bootstrap (docker mock doesn't have token)
|
||||
if not require_token(self):
|
||||
json_response(self, 401, {"message": "invalid authentication"})
|
||||
return
|
||||
# Try to continue without auth for bootstrap scenarios
|
||||
pass
|
||||
|
||||
parts = self.path.split("/")
|
||||
if len(parts) >= 6:
|
||||
|
|
|
|||
|
|
@ -1,32 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/smoke-init.sh — End-to-end smoke test for disinto init
|
||||
# tests/smoke-init.sh — End-to-end smoke test for disinto init using mock Forgejo
|
||||
#
|
||||
# Expects a running Forgejo at SMOKE_FORGE_URL with a bootstrap admin
|
||||
# user already created (see .woodpecker/smoke-init.yml for CI setup).
|
||||
# Validates the full init flow: Forgejo API, user/token creation,
|
||||
# repo setup, labels, TOML generation, and cron installation.
|
||||
#
|
||||
# Uses mock Forgejo server (started by .woodpecker/smoke-init.yml).
|
||||
#
|
||||
# Required env: SMOKE_FORGE_URL (default: http://localhost:3000)
|
||||
# Required tools: bash, curl, jq, python3, git
|
||||
# Required tools: bash, curl, jq, git
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FORGE_URL="${SMOKE_FORGE_URL:-http://localhost:3000}"
|
||||
SETUP_ADMIN="setup-admin"
|
||||
SETUP_PASS="SetupPass-789xyz"
|
||||
TEST_SLUG="smoke-org/smoke-repo"
|
||||
MOCK_BIN="/tmp/smoke-mock-bin"
|
||||
MOCK_STATE="/tmp/smoke-mock-state"
|
||||
FAILED=0
|
||||
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; }
|
||||
pass() { printf 'PASS: %s\n' "$*"; }
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$MOCK_BIN" "$MOCK_STATE" /tmp/smoke-test-repo \
|
||||
"${FACTORY_ROOT}/projects/smoke-repo.toml" \
|
||||
"${FACTORY_ROOT}/docker-compose.yml"
|
||||
rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \
|
||||
"${FACTORY_ROOT}/projects/smoke-repo.toml"
|
||||
# Restore .env only if we created the backup
|
||||
if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then
|
||||
mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env"
|
||||
|
|
@ -40,11 +36,11 @@ trap cleanup EXIT
|
|||
if [ -f "${FACTORY_ROOT}/.env" ]; then
|
||||
cp "${FACTORY_ROOT}/.env" "${FACTORY_ROOT}/.env.smoke-backup"
|
||||
fi
|
||||
# Start with a clean .env (setup_forge writes tokens here)
|
||||
# Start with a clean .env (init writes tokens here)
|
||||
printf '' > "${FACTORY_ROOT}/.env"
|
||||
|
||||
# ── 1. Verify Forgejo is ready ──────────────────────────────────────────────
|
||||
echo "=== 1/6 Verifying Forgejo at ${FORGE_URL} ==="
|
||||
# ── 1. Verify mock Forgejo is ready ─────────────────────────────────────────
|
||||
echo "=== 1/6 Verifying mock Forgejo at ${FORGE_URL} ==="
|
||||
retries=0
|
||||
api_version=""
|
||||
while true; do
|
||||
|
|
@ -55,43 +51,24 @@ while true; do
|
|||
fi
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -gt 30 ]; then
|
||||
fail "Forgejo API not responding after 30s"
|
||||
fail "Mock Forgejo API not responding after 30s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
pass "Forgejo API v${api_version} (${retries}s)"
|
||||
pass "Mock Forgejo API v${api_version} (${retries}s)"
|
||||
|
||||
# Verify bootstrap admin user exists
|
||||
if curl -sf --max-time 5 "${FORGE_URL}/api/v1/users/${SETUP_ADMIN}" >/dev/null 2>&1; then
|
||||
pass "Bootstrap admin '${SETUP_ADMIN}' exists"
|
||||
else
|
||||
fail "Bootstrap admin '${SETUP_ADMIN}' not found — was Forgejo set up?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 2. Set up mock binaries ─────────────────────────────────────────────────
|
||||
# ── 2. Set up mock binaries (claude, tmux, docker) ───────────────────────────
|
||||
echo "=== 2/6 Setting up mock binaries ==="
|
||||
mkdir -p "$MOCK_BIN" "$MOCK_STATE"
|
||||
|
||||
# Store bootstrap admin credentials for the docker mock
|
||||
printf '%s:%s' "${SETUP_ADMIN}" "${SETUP_PASS}" > "$MOCK_STATE/bootstrap_creds"
|
||||
mkdir -p "$MOCK_BIN"
|
||||
|
||||
# ── Mock: docker ──
|
||||
# Routes 'docker exec' user-creation calls to the Forgejo admin API,
|
||||
# using the bootstrap admin's credentials.
|
||||
# Routes 'docker exec' Forgejo CLI commands to the Forgejo API.
|
||||
cat > "$MOCK_BIN/docker" << 'DOCKERMOCK'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
FORGE_URL="${SMOKE_FORGE_URL:-http://localhost:3000}"
|
||||
MOCK_STATE="/tmp/smoke-mock-state"
|
||||
|
||||
if [ ! -f "$MOCK_STATE/bootstrap_creds" ]; then
|
||||
echo "mock-docker: bootstrap credentials not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
BOOTSTRAP_CREDS="$(cat "$MOCK_STATE/bootstrap_creds")"
|
||||
|
||||
# docker ps — return empty (no containers running)
|
||||
if [ "${1:-}" = "ps" ]; then
|
||||
|
|
@ -141,7 +118,6 @@ if [ "${1:-}" = "exec" ]; then
|
|||
|
||||
# Create user via Forgejo admin API
|
||||
if ! curl -sf -X POST \
|
||||
-u "$BOOTSTRAP_CREDS" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/admin/users" \
|
||||
-d "{\"username\":\"${username}\",\"password\":\"${password}\",\"email\":\"${email}\",\"must_change_password\":false,\"login_name\":\"${username}\",\"source_id\":0}" \
|
||||
|
|
@ -159,7 +135,6 @@ if [ "${1:-}" = "exec" ]; then
|
|||
patch_body="${patch_body}}"
|
||||
|
||||
curl -sf -X PATCH \
|
||||
-u "$BOOTSTRAP_CREDS" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/admin/users/${username}" \
|
||||
-d "${patch_body}" \
|
||||
|
|
@ -195,7 +170,6 @@ if [ "${1:-}" = "exec" ]; then
|
|||
patch_body="${patch_body}}"
|
||||
|
||||
if ! curl -sf -X PATCH \
|
||||
-u "$BOOTSTRAP_CREDS" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/admin/users/${username}" \
|
||||
-d "${patch_body}" \
|
||||
|
|
@ -231,11 +205,8 @@ chmod +x "$MOCK_BIN/claude"
|
|||
printf '#!/usr/bin/env bash\nexit 0\n' > "$MOCK_BIN/tmux"
|
||||
chmod +x "$MOCK_BIN/tmux"
|
||||
|
||||
# No crontab mock — use real BusyBox crontab (available in the Forgejo
|
||||
# Alpine image). Cron entries are verified via 'crontab -l' in step 6.
|
||||
|
||||
export PATH="$MOCK_BIN:$PATH"
|
||||
pass "Mock binaries installed (docker, claude, tmux)"
|
||||
pass "Mock binaries installed (claude, tmux)"
|
||||
|
||||
# ── 3. Run disinto init ─────────────────────────────────────────────────────
|
||||
echo "=== 3/6 Running disinto init ==="
|
||||
|
|
@ -290,19 +261,21 @@ if [ "$repo_found" = false ]; then
|
|||
fail "Repo not found on Forgejo under any expected path"
|
||||
fi
|
||||
|
||||
# Labels exist on repo — use bootstrap admin to check
|
||||
setup_token=$(curl -sf -X POST \
|
||||
-u "${SETUP_ADMIN}:${SETUP_PASS}" \
|
||||
# Labels exist on repo
|
||||
# Create a token to check labels (using disinto-admin which was created by init)
|
||||
disinto_admin_pass="Disinto-Admin-456"
|
||||
verify_token=$(curl -sf -X POST \
|
||||
-u "disinto-admin:${disinto_admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_URL}/api/v1/users/${SETUP_ADMIN}/tokens" \
|
||||
"${FORGE_URL}/api/v1/users/disinto-admin/tokens" \
|
||||
-d '{"name":"smoke-verify","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || setup_token=""
|
||||
| jq -r '.sha1 // empty') || verify_token=""
|
||||
|
||||
if [ -n "$setup_token" ]; then
|
||||
if [ -n "$verify_token" ]; then
|
||||
label_count=0
|
||||
for repo_path in "${TEST_SLUG}" "dev-bot/smoke-repo" "disinto-admin/smoke-repo"; do
|
||||
label_count=$(curl -sf \
|
||||
-H "Authorization: token ${setup_token}" \
|
||||
-H "Authorization: token ${verify_token}" \
|
||||
"${FORGE_URL}/api/v1/repos/${repo_path}/labels?limit=50" 2>/dev/null \
|
||||
| jq 'length' 2>/dev/null) || label_count=0
|
||||
if [ "$label_count" -gt 0 ]; then
|
||||
|
|
@ -316,7 +289,7 @@ if [ -n "$setup_token" ]; then
|
|||
fail "Expected >= 5 labels, found ${label_count}"
|
||||
fi
|
||||
else
|
||||
fail "Could not obtain verification token from bootstrap admin"
|
||||
fail "Could not obtain verification token"
|
||||
fi
|
||||
|
||||
# ── 5. Verify local state ───────────────────────────────────────────────────
|
||||
|
|
@ -387,6 +360,50 @@ else
|
|||
fail "No cron entries found (crontab -l returned empty)"
|
||||
fi
|
||||
|
||||
# ── Mock state verification ─────────────────────────────────────────────────
|
||||
echo "=== Verifying mock Forgejo state ==="
|
||||
|
||||
# Query /mock/state to verify all expected API calls were made
|
||||
mock_state=$(curl -sf \
|
||||
-H "Authorization: token ${verify_token}" \
|
||||
"${FORGE_URL}/mock/state" 2>/dev/null) || mock_state=""
|
||||
|
||||
if [ -n "$mock_state" ]; then
|
||||
# Verify users were created
|
||||
users=$(echo "$mock_state" | jq -r '.users | length' 2>/dev/null) || users=0
|
||||
if [ "$users" -ge 3 ]; then
|
||||
pass "Mock state: ${users} users created (expected >= 3)"
|
||||
else
|
||||
fail "Mock state: expected >= 3 users, found ${users}"
|
||||
fi
|
||||
|
||||
# Verify repos were created
|
||||
repos=$(echo "$mock_state" | jq -r '.repos | length' 2>/dev/null) || repos=0
|
||||
if [ "$repos" -ge 1 ]; then
|
||||
pass "Mock state: ${repos} repos created (expected >= 1)"
|
||||
else
|
||||
fail "Mock state: expected >= 1 repos, found ${repos}"
|
||||
fi
|
||||
|
||||
# Verify labels were created
|
||||
labels_total=$(echo "$mock_state" | jq '[.labels.values[] | length] | add // 0' 2>/dev/null) || labels_total=0
|
||||
if [ "$labels_total" -ge 5 ]; then
|
||||
pass "Mock state: ${labels_total} labels created (expected >= 5)"
|
||||
else
|
||||
fail "Mock state: expected >= 5 labels, found ${labels_total}"
|
||||
fi
|
||||
|
||||
# Verify tokens were created
|
||||
tokens=$(echo "$mock_state" | jq -r '.tokens | length' 2>/dev/null) || tokens=0
|
||||
if [ "$tokens" -ge 1 ]; then
|
||||
pass "Mock state: ${tokens} tokens created (expected >= 1)"
|
||||
else
|
||||
fail "Mock state: expected >= 1 tokens, found ${tokens}"
|
||||
fi
|
||||
else
|
||||
fail "Could not query /mock/state endpoint"
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue