diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 6c021b8..cf82550 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -89,7 +89,25 @@ services: - "${FORGEJO_TEST_HTTP_PORT:-3000}:3000" restart: unless-stopped + runner: + image: data.forgejo.org/forgejo/runner:13 + profiles: ["test-runner"] + user: "0:0" + working_dir: /data + environment: + DOCKER_HOST: unix:///var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - runner-data:/data + - ${FMCP_RUNNER_CONFIG_FILE:-./secrets/runner-config.yml}:/data/runner-config.yml:ro + command: forgejo-runner daemon --config /data/runner-config.yml + restart: unless-stopped + depends_on: + forgejo: + condition: service_started + volumes: postgres-data: forgejo-data: forgejo-config: + runner-data: diff --git a/scripts/test-full-docker-e2e.sh b/scripts/test-full-docker-e2e.sh index b2b8371..46856be 100755 --- a/scripts/test-full-docker-e2e.sh +++ b/scripts/test-full-docker-e2e.sh @@ -27,6 +27,7 @@ export FMCP_ALLOW_INSECURE_FORGEJO_HTTP=true export FMCP_ENVIRONMENT=test export FMCP_ADMIN_PASSWORD_FILE="$temporary_dir/admin_password" export FMCP_CREDENTIAL_KEY_FILE="$temporary_dir/credential_key" +export FMCP_RUNNER_CONFIG_FILE="$temporary_dir/runner-config.yml" export FMCP_E2E_ADMIN_PASSWORD="Admin-E2E-pass-123!" export FMCP_E2E_DEVELOPER_PASSWORD="Developer-E2E-pass-123!" export FMCP_E2E_REVIEWER_PASSWORD="Reviewer-E2E-pass-123!" @@ -44,14 +45,15 @@ PY chmod 0600 "$FMCP_ADMIN_PASSWORD_FILE" "$FMCP_CREDENTIAL_KEY_FILE" compose() { - docker compose -p "$project" -f deploy/compose.yaml --profile test-forgejo "$@" + docker compose -p "$project" -f deploy/compose.yaml \ + --profile test-forgejo --profile test-runner "$@" } cleanup() { status=$? if [ "$status" -ne 0 ]; then echo "Full Docker E2E failed; recent service logs:" >&2 - compose logs --tail=120 app postgres forgejo >&2 || true + compose logs --tail=120 app postgres forgejo runner >&2 || true fi compose down -v --remove-orphans --rmi local >/dev/null 2>&1 || true rm -rf "$temporary_dir" @@ -59,7 +61,7 @@ cleanup() { } trap cleanup EXIT INT TERM -compose up -d --build +compose up -d --build app postgres forgejo ready=false for _ in $(seq 1 120); do @@ -107,4 +109,36 @@ compose exec -T forgejo forgejo admin user create \ --email reviewer@example.test \ --must-change-password=false >/dev/null +runner_secret=$(uv run python -c 'import secrets; print(secrets.token_hex(20))') +runner_uuid=$(compose exec -T forgejo forgejo forgejo-cli actions register \ + --name full-docker-e2e --secret "$runner_secret" | tr -d '\r\n') +if [ -z "$runner_uuid" ]; then + echo "Forgejo runner registration did not return a UUID" >&2 + exit 1 +fi +cat > "$FMCP_RUNNER_CONFIG_FILE" < None: "path": ".forgejo/workflows/ci.yml", "content": ( "name: CI\non:\n workflow_dispatch:\n" + " inputs:\n delay:\n required: false\n" + " default: '0'\n" "jobs:\n test:\n runs-on: docker\n" - " steps:\n - run: echo ok\n" + " steps:\n - name: Produce result\n run: |\n" + " sleep '${{ inputs.delay }}'\n" + " echo ok | tee result.txt\n" ), }, ], @@ -569,6 +573,19 @@ def run_mcp_flow(mcp_tokens: dict[str, str]) -> None: {**repository, "ref": feature["commit_sha"]}, ) assert status["state"] in {"pending", "success", "failure", "error", "warning"} + + # Forgejo calculates PR mergeability asynchronously. A merge attempted while + # that check is pending returns HTTP 405 ("Please try again later"). + for _ in range(30): + merge_candidate = developer.call( + "forgejo_get_pull_request", {**repository, "number": pull["number"]} + ) + if merge_candidate["mergeable"] is True: + break + time.sleep(1) + else: + raise RuntimeError("pull request did not become mergeable within 30 seconds") + developer.call( "forgejo_merge_pull_request", { @@ -590,8 +607,96 @@ def run_mcp_flow(mcp_tokens: dict[str, str]) -> None: assert merged_status == {"number": pull["number"], "merged": True} developer.call( "forgejo_dispatch_workflow", - {**repository, "workflow": "ci.yml", "ref": "main", "inputs": {}}, + { + **repository, + "workflow": "ci.yml", + "ref": "main", + "inputs": {"delay": "0"}, + }, + ) + completed_run: dict[str, Any] | None = None + for _ in range(120): + action_runs = developer.call( + "forgejo_list_action_runs", + { + **repository, + "event": ["workflow_dispatch"], + "workflow_id": "ci.yml", + "limit": 30, + }, + ) + if action_runs["items"]: + candidate = action_runs["items"][0] + if candidate["status"] == "success": + completed_run = candidate + break + if candidate["status"] in {"failure", "cancelled", "skipped", "blocked"}: + raise RuntimeError(f"action run ended with unexpected status {candidate['status']}") + time.sleep(1) + if completed_run is None: + raise RuntimeError("action run did not complete within 120 seconds") + + run_id = completed_run["id"] + loaded_run = developer.call("forgejo_get_action_run", {**repository, "run_id": run_id}) + assert loaded_run["status"] == "success" + jobs = developer.call("forgejo_list_action_run_jobs", {**repository, "run_id": run_id}) + assert len(jobs["items"]) == 1 + job = jobs["items"][0] + assert job["status"] == "success" + job_log = developer.call( + "forgejo_get_action_job_log", + { + **repository, + "job_id": job["id"], + "attempt": job["attempt"], + }, ) + assert "ok" in job_log["content"] + run_logs = developer.call("forgejo_get_action_run_logs", {**repository, "run_id": run_id}) + assert run_logs["files"] + artifacts = developer.call( + "forgejo_list_action_run_artifacts", + {**repository, "run_id": run_id, "limit": 30}, + ) + assert artifacts["items"] == [] + + developer.call( + "forgejo_dispatch_workflow", + { + **repository, + "workflow": "ci.yml", + "ref": "main", + "inputs": {"delay": "60"}, + }, + ) + cancellable_run: dict[str, Any] | None = None + for _ in range(30): + action_runs = developer.call( + "forgejo_list_action_runs", + {**repository, "event": ["workflow_dispatch"], "limit": 30}, + ) + cancellable_run = next( + (item for item in action_runs["items"] if item["id"] != run_id), None + ) + if cancellable_run is not None: + break + time.sleep(1) + if cancellable_run is None: + raise RuntimeError("second action run did not appear within 30 seconds") + cancelled_run_id = cancellable_run["id"] + developer.call("forgejo_cancel_action_run", {**repository, "run_id": cancelled_run_id}) + for _ in range(30): + cancelled_run = developer.call( + "forgejo_get_action_run", {**repository, "run_id": cancelled_run_id} + ) + if cancelled_run["status"] == "cancelled": + break + time.sleep(1) + else: + raise RuntimeError("action run did not become cancelled within 30 seconds") + developer.call("forgejo_delete_action_run", {**repository, "run_id": cancelled_run_id}) + print("PASS MCP Actions runs, jobs, logs, artifacts, cancellation, and deletion") + tag = developer.call( "forgejo_create_tag", {