CI/CD pipeline
The repository myfoxit/northplane (private, default branch main) has three GitHub Actions workflows:
| Workflow | File | Trigger | Result |
|---|---|---|---|
| CI | .github/workflows/ci.yml |
push to main/master, pull requests |
quality gate: UI, docs, typed-codegen drift, lint, tests, e2e, PostgreSQL matrix, cross-build |
| Deploy | .github/workflows/deploy.yml |
CI completed successfully on main, or manual dispatch |
image ghcr.io/myfoxit/northplane:main-<sha12> + latest; rollout to np-01 (job deploy) |
| Release | .github/workflows/release.yml |
tag push v* |
GitHub Release with tarballs + checksums.txt; multi-arch image with semver tags |
From merge to live: the timeline
Section titled “From merge to live: the timeline”- Push/merge to
main. CI starts; all jobs run in parallel where possible (test,e2e,postgresandcross-buildwait forui). - CI finishes green. The
workflow_runtrigger starts Deploy (publishruns only whengithub.event.workflow_run.conclusion == 'success'; a manual dispatch always proceeds). Concurrency groupdeploy-production,cancel-in-progress: false— one rollout at a time, never cancelled mid-flight. publishchecks out the CI run’shead_sha, builds the Dockerfile with buildx forlinux/amd64andlinux/arm64(VERSION=main-<sha12>, GHA layer cache; the Dockerfile cross-compiles from the build platform, so no QEMU) and pushesghcr.io/myfoxit/northplane:main-<sha12>and:latest.deploy(np-01) runs afterpublish: SSH set-up → render.env→ ship the compose stack →docker compose pull/up -d→ verify → roll back on failure → summary line.- The verified run on 2026-08-23 (run
32629242562, mergedaa6dc5, 08:48 UTC) ended withpublishanddeploysucceeded (the since-removeddeploy-hetznerjob failed); the container onnp-01started at 08:50:57 UTC — under three minutes after the Deploy run began.
CI jobs
Section titled “CI jobs”| Job | Needs | What it does | Gate |
|---|---|---|---|
ui |
— | Node 22: npm ci, npm run lint, npm test (vitest), npm run build in web/; uploads internal/web/dist as artifact ui-dist |
blocking |
docs |
— | Node 22: npm ci and npm run build in docs/ — the Starlight build fails on any broken internal link or anchor (starlight-links-validator) and on a page that does not compile; uploads docs-dist |
blocking |
types |
— | Go 1.25 + Node 22: make types-check — regenerates web/src/types.gen.ts and docs/src/assets/openapi.json from northplaned openapi and fails on a diff |
blocking |
lint |
— | gofmt -l cmd internal must be empty; golangci-lint v2.12.2 (go installed, per .golangci.yml) |
blocking |
test |
ui |
matrix ubuntu-latest + macos-latest: go vet ./..., go test -race ./..., builds northplaned, np, np-agent |
blocking |
e2e |
ui |
builds northplaned with the embedded UI, installs Playwright Chromium, npm run test:e2e against northplaned --demo (locale de-DE); uploads playwright-report |
blocking |
postgres |
ui |
go test ./internal/storage/... with NORTHPLANE_TEST_PG_DSN against a postgres:16 service |
non-blocking (continue-on-error: true) — known TestAuditChain failure on PostgreSQL (jsonb normalises the hashed JSON) |
cross-build |
ui |
CGO_ENABLED=0 builds for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 — northplaned is skipped on windows (needs unix process groups), np and np-agent build everywhere |
blocking |
Locally: make test, make race, make e2e, make types-check, cd docs && npm run build. See Testing.
The Deploy workflow
Section titled “The Deploy workflow”Triggers and inputs
Section titled “Triggers and inputs”on: workflow_run: workflows: ["CI"] types: [completed] branches: [main] workflow_dispatch: inputs: demo: description: "Demo/real data switch for this run" type: choice options: [repo-default, "true", "false"] default: repo-default
concurrency: group: deploy-production cancel-in-progress: false
permissions: contents: read packages: write # push the image to ghcr.ioManual dispatch (Actions → Deploy → Run workflow) re-publishes the image for the current main and rolls it out; the demo dropdown overrides the repository variable NORTHPLANE_DEMO for np-01 and for that run only (repo-default keeps the variable).
Job publish
Section titled “Job publish”- name: Resolve image ref id: img run: | SHA="${{ github.event.workflow_run.head_sha || github.sha }}" echo "ver=main-${SHA::12}" >> "$GITHUB_OUTPUT"- name: Build and push image uses: docker/build-push-action@v6 with: context: . push: true build-args: VERSION=${{ steps.img.outputs.ver }} tags: | ghcr.io/${{ github.repository }}:${{ steps.img.outputs.ver }} ghcr.io/${{ github.repository }}:latest cache-from: type=gha cache-to: type=gha,mode=maxThe VERSION build arg becomes main.version in the binary, so GET /api/v1/system/info on the instance reports exactly the image tag ("version":"main-daa6dc518a2b").
Job deploy (np-01)
Section titled “Job deploy (np-01)”| Step | What happens on the runner / the VM |
|---|---|
| Set up SSH | writes secrets.DEPLOY_SSH_KEY to ~/.ssh/deploy_key, vars.DEPLOY_KNOWN_HOSTS to ~/.ssh/known_hosts, and an ssh config Host prod = vars.DEPLOY_HOST:vars.DEPLOY_PORT as vars.DEPLOY_USER, StrictHostKeyChecking yes |
Render .env |
DEMO = vars.NORTHPLANE_DEMO, overridden by the dispatch input unless repo-default; must be true or false; MODE_DIR = demo or real. Writes: NORTHPLANE_IMAGE=ghcr.io/<repo>:<ver>, NORTHPLANE_BASE_URL=<vars>, NORTHPLANE_DEMO=$DEMO, NORTHPLANE_ALLOW_SIGNUP=$SIGNUP (true only when the repo variable NORTHPLANE_ALLOW_SIGNUP is true, otherwise false), NORTHPLANE_DATA_DIR=/var/lib/northplane/$MODE_DIR, NP_DEFAULT_ADMIN_EMAIL=<vars>, NP_DEFAULT_ADMIN_PASSWORD=<secret> |
| Ship compose stack | on the VM: cp .env .env.previous; then scp deploy/docker-compose.vm.yml prod:$DEPLOY_PATH/docker-compose.yml and the rendered .env |
| Pull and roll forward | docker login ghcr.io with the run’s ephemeral GITHUB_TOKEN (the server never stores a registry credential), docker compose pull -q, docker logout ghcr.io, docker compose up -d --remove-orphans |
| Verify rollout | up to 12 × 5 s: docker inspect --format '{{.Config.Image}}' northplane-northplane-1 must equal the wanted image and curl -fsS -m 5 http://localhost:8443/healthz must return ok |
| Rollback on failure | only if verify failed: mv .env.previous .env && docker compose up -d — the previous image tag comes back |
| Summary | Deployed ghcr.io/…:<ver> to <DEPLOY_HOST>:2201 (VM saas1) — live at <NORTHPLANE_BASE_URL>. in the job summary |
WANT="ghcr.io/${{ github.repository }}:${{ needs.publish.outputs.ver }}"for i in $(seq 1 12); do RUNNING=$(ssh prod "docker inspect --format '{{.Config.Image}}' northplane-northplane-1 2>/dev/null" || true) HEALTH=$(ssh prod "curl -fsS -m 5 http://localhost:8443/healthz" || true) if [ "$RUNNING" = "$WANT" ] && [ "$HEALTH" = "ok" ]; then echo "live: $RUNNING (healthz ok)"; exit 0 fi echo "waiting ($i/12): image='$RUNNING' health='$HEALTH'"; sleep 5doneecho "rollout did not become healthy"; exit 1Former job deploy-hetzner (np-02) — removed
Section titled “Former job deploy-hetzner (np-02) — removed”The standalone Hetzner box np-02 (91.98.92.10) was reclaimed and its IP reassigned to a
stranger’s host; every run of its deploy-hetzner job failed at “Ship compose stack” from
2026-08-14 on. The job was removed from deploy.yml so a rollout can no longer attempt to
ship credentials to a host we do not own. To bring a standalone box back: provision it
(recreation checklist), restore the
job from git history, and rotate HETZNER_HOST/HETZNER_KNOWN_HOSTS/HETZNER_SSH_KEY/
HETZNER_ADMIN_PASSWORD first. The still-configured HETZNER_* variables and secrets are
unused and stale until then.
Reading a red run
Section titled “Reading a red run”A red Deploy run does not always mean production missed the rollout:
- Open the run and look at the per-job conclusions.
publishgreen +deploygreen =np-01is live on the new image. - Confirm on the instance:
curl -s https://doktrace.com/api/v1/system/info—versionmust equalmain-<sha12>of the merge commit. - If
deployitself is red: the “Verify rollout” step tells you whether the image never changed (pull/login problem) or the app never returnedokon/healthz(it crashed on start — readdocker compose logs northplaneon the VM). The rollback step has already restored.env.previous; production is on the previous tag. - If CI was red, Deploy never started (
workflow_runwith a non-success conclusion skipspublish); fixmainor re-run the failed CI jobs (gh run rerun --failed <id>). Known flaky/non-blocking: thepostgresjob.
Image tagging
Section titled “Image tagging”| Tag | Produced by | Meaning |
|---|---|---|
ghcr.io/myfoxit/northplane:main-<sha12> |
Deploy publish |
immutable per green build of main; linux/amd64 + linux/arm64; VERSION baked in; what production pins in .env |
ghcr.io/myfoxit/northplane:latest |
Deploy publish and Release docker |
moving; whichever ran last. Fine for trials, never for production pins |
ghcr.io/myfoxit/northplane:<major>.<minor>.<patch> and <major>.<minor> |
Release docker (tag v*) |
semver, linux/amd64 + linux/arm64, VERSION=<tag> |
The package is public; the deploy jobs nevertheless log in with the run’s GITHUB_TOKEN (packages: read) for the pull and log out immediately after — the servers never store a registry credential.
GitHub configuration
Section titled “GitHub configuration”Created under Settings → Secrets and variables → Actions (values verified with gh variable list on 2026-08-23).
Repository variables
Section titled “Repository variables”| Variable | Value | Used by |
|---|---|---|
DEPLOY_HOST |
51.83.96.40 |
deploy — ssh target (the hypervisor; DNAT forwards to the VM) |
DEPLOY_PORT |
2201 |
deploy — the DNAT port → VM :22 |
DEPLOY_USER |
deploy |
deploy |
DEPLOY_PATH |
/opt/northplane |
deploy — compose project dir on the VM |
DEPLOY_KNOWN_HOSTS |
[51.83.96.40]:2201 ssh-ed25519 … |
deploy — pins the VM host key (StrictHostKeyChecking yes) |
DEPLOY_DOMAIN |
doktrace.com |
informational — not referenced by any workflow |
NORTHPLANE_BASE_URL |
https://doktrace.com |
deploy — .env |
NORTHPLANE_DEMO |
false (set 2026-08-20) |
deploy — the demo/real switch |
NP_DEFAULT_ADMIN_EMAIL |
[email protected] |
deploy — .env |
HETZNER_HOST |
91.98.92.10 |
unused since the deploy-hetzner job was removed — stale, host is gone |
HETZNER_KNOWN_HOSTS |
91.98.92.10 ssh-ed25519 … |
unused — stale |
Repository secrets (names only)
Section titled “Repository secrets (names only)”| Secret | Purpose |
|---|---|
DEPLOY_SSH_KEY |
private half of the CI deploy key authorised for deploy@VM101 by provision-server.sh |
NP_DEFAULT_ADMIN_PASSWORD |
break-glass admin password for np-01 ([email protected]); change the password in the UI after first login — the secret only seeds |
HETZNER_SSH_KEY |
CI deploy key for np-02 (stale) |
HETZNER_ADMIN_PASSWORD |
np-02 break-glass admin (root@localhost) (stale) |
No registry secret exists — GHCR pulls on the servers use the workflow’s GITHUB_TOKEN.
The Release workflow (tags v*)
Section titled “The Release workflow (tags v*)”| Job | What it does |
|---|---|
ui |
builds the UI (web/) and the docs (docs/, npm run build:embed); uploads ui-dist and docs-dist |
binaries |
matrix linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64; VERSION="${GITHUB_REF_NAME#v}" (tag without v) via ldflags, CGO_ENABLED=0 -trimpath -ldflags "-s -w"; downloads ui-dist → internal/web/dist and docs-dist → internal/docs/dist so the binaries embed both; packs northplane_<tag>_<os>_<arch>.tar.gz (northplaned np np-agent LICENSE) or northplane_<tag>_windows_amd64.zip (np.exe np-agent.exe LICENSE — no northplaned on Windows) plus a .sha256 each |
release |
aggregates checksums.txt, creates the GitHub Release with softprops/action-gh-release and generate_release_notes: true |
docker |
buildx + QEMU, pushes ghcr.io/myfoxit/northplane for linux/amd64,linux/arm64 with tags {{version}}, {{major}}.{{minor}}, latest, VERSION=<tag> |
Asset names are a contract with install.sh (northplane_<tag>_<os>_<arch>.tar.gz — note the tag keeps its v; the embedded version string drops it). The only release so far is the pre-release v0.0.0-rc1 (2026-06-07) with linux/darwin tarballs and checksums.txt. Release mechanics and checklists: Release process.
How the documentation ships
Section titled “How the documentation ships”The pages you are reading are built from docs/ and embedded into northplaned, which serves them at /docs/ on every instance — public, no login, with their own Content-Security-Policy.
| Where | How |
|---|---|
| Dockerfile | stage docs (node:22-alpine, npm ci --legacy-peer-deps, npm run build:embed) → COPY --from=docs /docs/dist ./internal/docs/dist before the Go build → every CD image carries the manual matching its commit |
| Release binaries | the ui job’s docs-dist artifact is placed in internal/docs/dist before go build |
| CI | the docs job is the quality gate (build + link validation); its artifact is not embedded into the CI test binaries |
| Local | make docs builds and stages docs/dist into internal/docs/dist (git-ignored except .gitkeep); a plain go build without staging still compiles, and /docs/ then answers 501 documentation not embedded in this build — run make docs |
| OpenAPI | docs/src/assets/openapi.json is a copy of northplaned openapi, refreshed by make types and drift-checked by make types-check (CI job types); the REST reference pages under /docs/reference/api/ are generated from it |
The image verified on np-01 on 2026-08-23 (main-daa6dc518a2b) was built before the docs stage existed, so https://doktrace.com/docs/ serves the documentation only from the first deploy that includes the docs build. Authoring workflow: Documentation.
Related: Provisioning (first deploy to a new host), Operations (redeploy, rollback, switching modes), Upgrades.