Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 194 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -623,15 +623,208 @@ jobs:
docker rm -f bridge 2>/dev/null
docker rm -f plane-stub 2>/dev/null

# Real-Plane round-trip. Brings up a minimum Plane CE v1.3.1 stack
# (api+worker+deps, no frontends), seeds an admin user + workspace +
# project + API token via direct Django ORM (Plane CE has no headless
# signup flow), then runs the **bridge image built by build-image**
# against that real Plane and POSTs a synthetic forge issue webhook
# at it. Asserts the bridge translated the webhook into a real work
# item in real Plane (looked up via external_source/_id, the same
# round-trip the production deploy relies on).
#
# Catches the wire-shape drift class of bug that produced PFB-22,
# PFB-24, and PFB-25 — those all passed CI because the e2e-docker
# plane-stub happily agreed with the bridge's synthetic testdata.
#
# Separate from the e2e-docker matrix: Plane CE boot is ~90s on a
# cold cache, and the wire shape doesn't vary by forge flavor, so
# running once is enough.
e2e-real-plane:
name: e2e-real-plane
needs: build-image
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
timeout-minutes: 20
env:
PFB_FORGE_WEBHOOK_SECRET: ci-forge-secret-${{ github.run_id }}
PFB_PLANE_API_KEY: ci-plane-token-${{ github.run_id }}
PFB_PLANE_WEBHOOK_SECRET: ci-plane-secret-${{ github.run_id }}
steps:
- uses: actions/checkout@v6

- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Bring up Plane CE v1.3.1 + seed
id: seed
run: |
set -euo pipefail
seed_json=$(bash test/e2e-docker/plane-ce/run.sh up | tail -n1)
echo "seed_json=$seed_json"
token=$(echo "$seed_json" | jq -r '.api_token')
project_id=$(echo "$seed_json" | jq -r '.project_id')
workspace_slug=$(echo "$seed_json" | jq -r '.workspace_slug')
echo "::add-mask::$token"
# Override the run-id-derived API key with the actual seeded token
# (the seed honours the env hint but we re-export to be sure).
echo "PFB_PLANE_API_KEY=$token" >> "$GITHUB_ENV"
echo "PFB_PLANE_WORKSPACE_SLUG=$workspace_slug" >> "$GITHUB_ENV"
echo "PFB_PLANE_PROJECT_ID=$project_id" >> "$GITHUB_ENV"

- name: Pull bridge image (with retry against ghcr transient denies)
env:
IMAGE: ${{ needs.build-image.outputs.image }}
run: |
set -euo pipefail
# ghcr.io intermittently returns "denied: denied" on freshly-pushed
# multi-arch manifests for a few seconds after the push. Re-login
# and retry up to 6 times with backoff before giving up.
for i in 1 2 3 4 5 6; do
if docker pull "$IMAGE"; then
echo "pulled after $i attempt(s)"; exit 0
fi
echo "pull failed; re-authenticating and retrying in $((i*5))s"
echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io \
-u '${{ github.actor }}' --password-stdin
sleep $((i*5))
done
echo "ERROR: docker pull never succeeded"; exit 1

- name: Write bridge config pointing at real Plane
run: |
set -euo pipefail
# The bridge joins the pfb-e2e-plane-ce network (the one the
# compose creates) so it can address plane-api by service name.
# /api/v1 prefix is part of base_url (matches config.example.yaml).
cat > /tmp/pfb-config.yaml <<YAML
listen: "0.0.0.0:8080"
log_level: debug
forge:
# Forge isn't used on the forge -> plane direction the test
# exercises (the bridge only writes to plane). Point at a stub
# URL so config validation passes.
base_url: http://forge.invalid
token_env: PFB_FORGE_TOKEN
webhook_secret_env: PFB_FORGE_WEBHOOK_SECRET
plane:
base_url: http://plane-api:8000/api/v1
workspace_slug: ${PFB_PLANE_WORKSPACE_SLUG}
api_key_env: PFB_PLANE_API_KEY
webhook_secret_env: PFB_PLANE_WEBHOOK_SECRET
bridge_bot:
forge_username: pfb-bot
plane_member_id: 00000000-0000-0000-0000-000000000000
links:
- forge_repo: acme/widget
plane_project_id: ${PFB_PLANE_PROJECT_ID}
project_identifier: PFB
state_map:
open: Backlog
closed: Done
pr_state_map:
opened: "In Progress"
merged: "Done"
closed: "Cancelled"
users: {}
YAML

- name: Launch bridge on the plane-ce network
env:
IMAGE: ${{ needs.build-image.outputs.image }}
run: |
set -euo pipefail
docker run -d --name pfb-bridge \
--network pfb-e2e-plane-ce \
-p 8080:8080 \
-v /tmp/pfb-config.yaml:/etc/pfb/config.yaml:ro \
-e PFB_FORGE_TOKEN=placeholder \
-e PFB_FORGE_WEBHOOK_SECRET="$PFB_FORGE_WEBHOOK_SECRET" \
-e PFB_PLANE_API_KEY="$PFB_PLANE_API_KEY" \
-e PFB_PLANE_WEBHOOK_SECRET="$PFB_PLANE_WEBHOOK_SECRET" \
"$IMAGE" \
--config /etc/pfb/config.yaml
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
echo "bridge healthy after ${i}s"; break
fi
sleep 1
done
curl -fsS http://127.0.0.1:8080/healthz

- name: POST synthetic forge issue webhook
run: |
set -euo pipefail
body=$(cat internal/forge/testdata/issues_opened.json)
sig=$(printf '%s' "$body" | openssl dgst -sha256 \
-hmac "$PFB_FORGE_WEBHOOK_SECRET" -hex | awk '{print $NF}')
status=$(curl -sS -o /tmp/resp -w '%{http_code}' \
-X POST http://127.0.0.1:8080/forge/webhook \
-H "X-Gitea-Signature: $sig" \
-H "X-Gitea-Event: issues" \
-H "X-Gitea-Delivery: e2e-real-plane-${{ github.run_id }}" \
-H 'Content-Type: application/json' \
--data-binary "$body")
echo "status=$status body=$(cat /tmp/resp)"
[ "$status" = "202" ] || { echo "ERROR: bridge returned $status"; exit 1; }

- name: Assert real Plane has the mirrored work item
run: |
set -euo pipefail
# The bridge stamps external_source="forge:acme/widget" and
# external_id="42" on the create. Plane's per-project issues
# endpoint short-circuits to a single get when both are set,
# returning 200 with the bare WorkItem on a hit or 404 on a
# miss. Translator + decode + POST happens asynchronously
# relative to the 202 ack, so poll briefly.
for i in $(seq 1 30); do
http=$(curl -sS -o /tmp/wi.json -w '%{http_code}' \
-H "X-API-Key: $PFB_PLANE_API_KEY" \
"http://localhost:8765/api/v1/workspaces/$PFB_PLANE_WORKSPACE_SLUG/projects/$PFB_PLANE_PROJECT_ID/issues/?external_source=forge:acme/widget&external_id=42")
if [ "$http" = "200" ]; then
echo "work item found after ${i}s"
jq '{id, name, sequence_id, external_source, external_id, state}' /tmp/wi.json
# PFB-25 regression check: state must decode, not break the
# bridge. If decode broke, the bridge's POST log would show
# the unmarshal error and no work item would land.
state_id=$(jq -r '.state // empty' /tmp/wi.json)
[ -n "$state_id" ] || { echo "ERROR: state empty in response"; exit 1; }
exit 0
fi
sleep 2
done
echo "ERROR: work item never appeared in real Plane"
docker logs pfb-bridge --tail=80 || true
exit 1

- name: Bridge logs (always)
if: always()
run: docker logs pfb-bridge --tail=120 || true

- name: Plane logs (always)
if: always()
run: |
docker compose -f test/e2e-docker/plane-ce/docker-compose.yaml \
logs --tail=80 plane-api plane-worker || true

- name: Tear down
if: always()
run: |
docker rm -f pfb-bridge 2>/dev/null || true
bash test/e2e-docker/plane-ce/run.sh down

# Publish on push to main (rolling `:latest`) and on v* tag pushes (semver
# tags). Never on PRs. Retag the build-image artifact rather than rebuild —
# the bytes we publish are the bytes e2e-docker just validated.
publish:
name: publish
needs: [lint, build-image, e2e-docker]
needs: [lint, build-image, e2e-docker, e2e-real-plane]
if: |
github.event_name != 'pull_request' &&
needs.e2e-docker.result == 'success' &&
needs.e2e-real-plane.result == 'success' &&
needs.lint.result == 'success'
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
steps:
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to plane-forge-bridge are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Changed

- **CI gains a real-Plane round-trip job (PFB-28).** The
`e2e-real-plane` workflow stage brings up a minimum Plane CE v1.3.1
stack (api+worker+postgres+redis+rabbitmq+minio) via
`test/e2e-docker/plane-ce/docker-compose.yaml`, seeds an admin user /
workspace / project / API token via direct Django ORM
(`seed.py` — Plane CE has no headless signup flow), then runs the
**bridge image built by `build-image`** against that real Plane and
POSTs a synthetic Forgejo `issues.opened` webhook. Asserts the
resulting work item in real Plane via REST (`external_source` +
`external_id` round-trip) — exactly the path PFB-25 broke in
production. The publish gate now requires this job alongside `lint`
and `e2e-docker`. Goal: future Plane wire-shape changes (the
PFB-22 / PFB-24 / PFB-25 class) fail in CI instead of in production —
the existing `plane-stub` happily agrees with synthetic testdata, so
it can't catch upstream drift on its own.

## [0.1.4] — 2026-05-24

### Fixed
Expand Down
140 changes: 140 additions & 0 deletions test/e2e-docker/plane-ce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# test/e2e-docker/plane-ce — real Plane CE in CI

A minimal Plane CE v1.3.1 stack the bridge spins up to round-trip a
real forge webhook → translator → POST `/issues/` → assertion exchange
against the actual Plane backend (not the recording stub). This is the
structural fix from PFB-28 for the class of bug that produced PFB-22,
PFB-24, and PFB-25 — testdata that diverged from real Plane wire
shape, with the existing e2e stub happy to agree with synthetic data.

## What runs

`docker-compose.yaml` brings up the minimum Plane CE topology needed
for the REST API + webhook delivery worker:

- `plane-db` — postgres 15 (ephemeral, no volume)
- `plane-redis` — valkey 7
- `plane-mq` — rabbitmq 3.13 with management plugin
- `plane-minio` — S3-compatible storage for attachments (Plane requires
it even if nothing uses it)
- `plane-migrator` — one-shot django migrations
- `plane-api` — gunicorn serving `/api/v1/*` and `/auth/*`
- `plane-worker` — celery worker (processes webhook deliveries)
- `plane-beat-worker` — celery beat scheduler

Trimmed from the upstream `makeplane/plane` compose by dropping the
frontends (web, space, admin, live), the proxy, and all persistent
volumes — none are needed for headless REST + webhook coverage.

## What CI does

The `e2e-real-plane` job in `.github/workflows/ci.yaml`:

1. Brings up this stack via `run.sh up` (~90s on a cold cache)
2. Seeds an admin user + workspace + project + API token via direct
Django ORM (see `seed.py`)
3. Pulls **the bridge image built by `build-image`** (not the source —
this is the artifact that's about to be published)
4. Runs that image as a sibling container on the `pfb-e2e-plane-ce`
network, configured to talk to `plane-api:8000/api/v1`
5. POSTs a synthetic Forgejo `issues.opened` webhook
(`internal/forge/testdata/issues_opened.json`) at the bridge
6. Polls real Plane via REST for the work item the bridge should
have created, looking it up by `external_source=forge:acme/widget`
+ `external_id=42`. A 200 with a populated `state` field proves
the full round-trip (forge webhook → bridge decode → translator →
bridge POST → real Plane decode → REST GET round-trip) — exactly
the path PFB-25 broke in production.
7. Tears down on success or failure (`if: always()`).

The `publish` job depends on `e2e-real-plane` succeeding, alongside
`lint` and `e2e-docker`.

## Why these versions

The Plane backend is pinned to `v1.3.1` because that is the version
`plane.stern.ca` runs and that `internal/plane/testdata/` is locked to
(see `internal/plane/testdata/README.md`). Bumping the version requires:

1. Re-capturing the webhook payloads in `internal/plane/testdata/`
against the new version
2. Verifying that `seed.py` still finds the expected ORM surface (Plane
has moved User/Workspace models across module boundaries before)
3. Verifying that the `register_instance` and `configure_instance`
management commands still exist with the same arguments

The `valkey:7.2.11-alpine` / `postgres:15.7-alpine` / `rabbitmq:3.13.6-management-alpine`
pins match the upstream compose at the same Plane release; no reason to
drift them independently.

## Local run

```bash
# Bring the stack up + seed admin/workspace/project/token (~90s cold cache):
bash test/e2e-docker/plane-ce/run.sh up
# Last stdout line is JSON:
# {"workspace_slug":"pfb-ci","project_id":"...","api_token":"...", ...}

# Inspect the seeded REST surface:
TOKEN=<api_token from above>
curl -H "X-API-Key: $TOKEN" \
http://localhost:8765/api/v1/workspaces/pfb-ci/projects/ | jq

# Tear down:
bash test/e2e-docker/plane-ce/run.sh down
```

The seed is idempotent — re-running it returns the same workspace,
project, and token; pass `PFB_PLANE_API_KEY=<known-value>` to lock the
token to a stable string (CI does this for log grep-ability).

To exercise the full bridge round-trip locally, follow the same
sequence the CI job does (compose up + seed → run bridge image with
config pointing at `plane-api:8000` on the compose network → POST
signed webhook → assert via REST). See the `e2e-real-plane` block in
`.github/workflows/ci.yaml` for the exact commands.

### Podman wart

On rootless podman, `rabbitmq:3.13.6-management-alpine` fails to start
with `eacces` on `/var/lib/rabbitmq/.erlang.cookie` because the
container runs as root, which maps to a UID that lacks read access to
the in-image `/var/lib/rabbitmq/` (owned by uid 100). The compose pins
`user: "100:101"` on the rabbitmq service to work around this. Docker
(in CI) is unaffected either way.

Separately, rootless podman occasionally interprets `-v file:file`
bind mounts as directories (the destination doesn't exist yet, so
podman creates a directory at that path before the source is mounted).
The Linux Docker daemon in GitHub Actions doesn't have this quirk;
the e2e-real-plane CI job uses the same `-v config.yaml:...` pattern
the existing e2e-docker job uses without issue.

## Why the healthcheck uses 127.0.0.1, not localhost

`plane-api` gunicorn binds `0.0.0.0:8000` (IPv4 only). On some rootless
podman setups, `localhost` inside the container resolves to `::1`
first, and the wget healthcheck gets ECONNREFUSED. `127.0.0.1` is
unambiguous and works across Docker and Podman.

## Bootstrap details (seed.py)

Plane CE has no documented headless seed path. The browser flow
(register instance → sign up admin → create workspace → create project →
mint API token) is human-oriented and runs through Django sessions
with CSRF cookies; driving it from a script is more wire than it's
worth. `seed.py` runs inside the `plane-api` container and goes
directly through Django ORM:

- `Instance` row with `is_setup_done=True` (the gate the auth views
check before allowing sign-up)
- `User` with a hashed password + `is_email_verified=True`
- `InstanceAdmin` linking the user to the instance as Owner (role 20)
- `Workspace` (slug `pfb-ci`) + `WorkspaceMember`
- `Project` (identifier `PFB`) + `ProjectIdentifier` + `ProjectMember`
- Default `State` rows (Backlog/Todo/In Progress/Done/Cancelled) — Plane
normally seeds these via a post-create signal, but we create them
explicitly so state-map tests are deterministic
- `APIToken` with a known value (env override or random UUID hex)

Every step is `get_or_create`, so the seed is safe to re-run.
Loading
Loading