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
169 changes: 169 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Deploy IPAM

# Push-to-main CI/CD: build the image, push to ghcr.io/thenulldev/ipam, and
# trigger /srv/ipam/deploy.sh on this host over SSH.
#
# See docs/operations/deploy-workflow.md for the full ops write-up and the
# list of repo secrets the workflow reads.
#
# workflow_dispatch exists for two purposes:
# 1. Manual deploy (e.g. after a hotfix that bypassed CI).
# 2. Rollback — `inputs.pinned_tag` lets an operator rerun ONLY the deploy
# step against a known-good SHA without rebuilding the image.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
pinned_tag:
description: >
Override the tag passed to /srv/ipam/deploy.sh. Leave empty on a push
trigger (the SHA is auto-derived). Set to a previous SHA for rollback.
The image must already exist at ghcr.io/thenulldev/ipam:<pinned_tag>.
required: false
type: string
deploy_only:
description: >
If 'true', skip the build + push job and run only the deploy step
against the supplied pinned_tag (or github.sha if pinned_tag is
empty). Use this for rollback when the image is already in ghcr.io.
required: false
type: choice
default: 'false'
options:
- 'false'
- 'true'

# Never kill an in-flight deploy. Two pushes to main in quick succession
# MUST serialize — otherwise the second push could `docker compose up -d`
# while the first is still pulling, which would race on the named volume
# and produce a half-up container.
concurrency:
group: ipam-deploy-${{ github.ref }}
cancel-in-progress: false

env:
# The image registry. Override via the IPAM_DOCKER_REGISTRY var on the
# repo to retarget (e.g. a staging registry) without editing this file.
IMAGE_BASE: ${{ vars.IPAM_DOCKER_REGISTRY || 'ghcr.io' }}/${{ github.repository }}

jobs:
build:
name: Build & push image
# Skip on a workflow_dispatch that explicitly opted into "deploy only".
if: ${{ github.event_name != 'workflow_dispatch' || inputs.deploy_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
# GITHUB_TOKEN-based ghcr.io auth: minimal secret surface. Falls back
# to IPAM_DOCKER_USERNAME/IPAM_DOCKER_PASSWORD (see "Alternate auth"
# note at the bottom of this file) if those secrets are set AND the
# founder prefers parity with the existing release.yml pipeline.
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to ghcr.io
# Default path: GitHub's automatic GITHUB_TOKEN with packages:write.
# `docker/login-action` accepts it directly — no PAT needed for
# push-to-same-org. If the founder prefers parity with release.yml,
# set secrets.IPAM_DOCKER_USERNAME + secrets.IPAM_DOCKER_PASSWORD
# and the conditional below picks them up automatically.
uses: docker/login-action@v3
with:
registry: ${{ vars.IPAM_DOCKER_REGISTRY || 'ghcr.io' }}
username: ${{ secrets.IPAM_DOCKER_USERNAME || github.actor }}
password: ${{ secrets.IPAM_DOCKER_PASSWORD || secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_BASE }}
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Cache by GH Actions cache backend (free, scoped to the repo).
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
# Plain name — `secrets` is not allowed in job.name (only in if:/steps).
name: SSH deploy
needs: build
# The deploy step is safe to re-run on workflow_dispatch WITHOUT rebuild.
# `always()` so we still run on a skipped build (deploy_only=true path);
# the explicit result check guards against an actual build failure.
if: ${{ always() && (needs.build.result == 'success' || needs.build.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 10
# The deploy job does not need any GitHub token scopes — it only uses
# an out-of-band SSH key (the deploy key scoped to paperclip@this-host).
permissions:
contents: read

steps:
- name: Determine deploy target tag
id: target
# Priority: explicit workflow_dispatch input > auto-derived SHA.
# For a normal push trigger, github.sha IS the SHA tag we just pushed.
run: |
if [ -n "${{ inputs.pinned_tag }}" ]; then
echo "tag=${{ inputs.pinned_tag }}" >> "$GITHUB_OUTPUT"
echo "Deploying pinned tag: ${{ inputs.pinned_tag }}"
else
echo "tag=${{ github.sha }}" >> "$GITHUB_OUTPUT"
echo "Deploying SHA from trigger: ${{ github.sha }}"
fi

- name: SSH deploy
# appleboy/ssh-action@v1 — pinned to major v1 for stability.
# We deliberately run ONE command (the authorized_keys-restricted
# `bash /srv/ipam/deploy.sh <tag>`) so the SSH key can be locked
# down on the host via `command="..."` in authorized_keys. NUL-225
# (Relay) owns that authorized_keys setup.
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.IPAM_DEPLOY_HOST }}
username: ${{ secrets.IPAM_DEPLOY_USER }}
key: ${{ secrets.IPAM_DEPLOY_SSH_KEY }}
command_timeout: 8m
script: |
set -euo pipefail
echo "::group::ipam deploy ${{ steps.target.outputs.tag }}"
bash /srv/ipam/deploy.sh ${{ steps.target.outputs.tag }}
echo "::endgroup::"

# ---------------------------------------------------------------------------
# Alternate auth note (kept as a comment for the reviewer):
#
# release.yml uses IPAM_DOCKER_USERNAME/IPAM_DOCKER_PASSWORD explicitly and
# gates on their presence. To match that exactly, replace the "Log in to
# ghcr.io" step above with:
#
# - name: Log in to ghcr.io
# uses: docker/login-action@v3
# with:
# registry: ${{ vars.IPAM_DOCKER_REGISTRY || 'ghcr.io' }}
# username: ${{ secrets.IPAM_DOCKER_USERNAME }}
# password: ${{ secrets.IPAM_DOCKER_PASSWORD }}
#
# and add `if: ${{ secrets.IPAM_DOCKER_USERNAME != '' }}` to gate the build
# job. Trade-off: two more repo secrets, but the workflow is identical in
# shape to release.yml. Founder picks — see docs/operations/deploy-workflow.md.
# ---------------------------------------------------------------------------
148 changes: 148 additions & 0 deletions docs/operations/deploy-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# IPAM deploy workflow (`deploy.yml`)

> Author: Forge · Founding Engineer (NUL-226).
> Parent: [NUL-221 — Local Docker Deploy](../README.md).
> Spec: see Compass's `documentKey=spec` on NUL-222.

This file is the operator-facing write-up for `.github/workflows/deploy.yml`.
It is the runbook the founder and Sentinel (review) reach for when they ask
"what does this workflow actually do, and what secrets does it need?".

## What it does

A push to `main` on `thenulldev/ipam`:

1. Builds the Docker image (the same multi-stage `Dockerfile` that `release.yml`
already pushes on tag pushes).
2. Tags it two ways and pushes to `ghcr.io/thenulldev/ipam`:
- `ghcr.io/thenulldev/ipam:<full-commit-sha>` — the pin we deploy.
- `ghcr.io/thenulldev/ipam:latest` — rolling, only on default branch.
3. SSHes to this host (`paperclip@<IPAM_DEPLOY_HOST>`) as `paperclip` using a
dedicated deploy key, and runs `bash /srv/ipam/deploy.sh <sha>`.

`/srv/ipam/deploy.sh` (authored by Relay under NUL-225) does the
`docker compose pull && docker compose up -d --remove-orphans && docker
compose ps` work and waits for the container's `/healthz` to go green before
exiting zero.

## Workflow triggers

| Trigger | Behaviour |
| ------------------------- | ------------------------------------------------------------ |
| `push` to `main` | Build → push → SSH deploy, always. |
| `workflow_dispatch` | Manual run from the Actions UI. By default also builds. |
| `workflow_dispatch` w/ `pinned_tag=<sha>` | Rollback: deploy `<sha>` instead of the freshly built one. |
| `workflow_dispatch` w/ `deploy_only=true` | Skip build; only run the SSH deploy step. |

The `concurrency` group is `ipam-deploy-${{ github.ref }}` with
`cancel-in-progress: false`, so two pushes in quick succession serialise.
The second push waits for the first deploy to finish before starting its own.
We never kill a half-finished deploy.

## Repo secrets

These live in the repo's **Settings → Secrets and variables → Actions**.
The workflow refuses to do anything useful without them.

| Secret | Required by | What it is |
| ---------------------- | ----------- | ----------------------------------------------------------------------------- |
| `IPAM_DEPLOY_HOST` | deploy job | Hostname or IP of this box (`paperclip.thenull.dev` / `2.25.87.37`). |
| `IPAM_DEPLOY_USER` | deploy job | SSH user (`paperclip`). Relay creates the user + restricts the key. |
| `IPAM_DEPLOY_SSH_KEY` | deploy job | Private half of an `ed25519` keypair whose public half is in `paperclip@this-host:~/.ssh/authorized_keys`, locked to `command="bash /srv/ipam/deploy.sh"`. Generated with `ssh-keygen -t ed25519 -C 'ipam-deploy'`. |
| `IPAM_DOCKER_USERNAME` | build job (optional) | ghcr.io username. Used only if the founder prefers parity with `release.yml`. Default path uses `GITHUB_TOKEN` instead. |
| `IPAM_DOCKER_PASSWORD` | build job (optional) | ghcr.io PAT or `GITHUB_TOKEN`. Same conditional as above. |

And the matching **variable** (optional):

| Variable | Purpose |
| ----------------------- | ------------------------------------------------------------------------------- |
| `IPAM_DOCKER_REGISTRY` | Override the registry hostname (default `ghcr.io`). Useful for staging mirrors. |

## How to add the secrets

```bash
# On the founder's machine:
gh secret set IPAM_DEPLOY_HOST --repo thenulldev/ipam --body "paperclip.thenull.dev"
gh secret set IPAM_DEPLOY_USER --repo thenulldev/ipam --body "paperclip"

# Generate a fresh ed25519 deploy key (do NOT reuse personal keys):
ssh-keygen -t ed25519 -C 'ipam-deploy-github-actions' -f /tmp/ipam-deploy
gh secret set IPAM_DEPLOY_SSH_KEY --repo thenulldev/ipam < /tmp/ipam-deploy
ssh-copy-id -i /tmp/ipam-deploy.pub paperclip@paperclip.thenull.dev
# Then on the host, lock the key down:
# echo 'command="bash /srv/ipam/deploy.sh",from="*.actions.githubusercontent.com",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... ipam-deploy-github-actions' \
# >> /home/paperclip/.ssh/authorized_keys
# Relay (NUL-225) wires this in; do not edit it from CI.
```

## How to roll back

> The detailed rollback runbook lives at
> [`ipam-rollback.md`](./ipam-rollback.md) (NUL-228). This section is a
> quick reference; the runbook has the full decision tree.

Easiest path — find a previously good SHA and use the workflow UI:

1. Open the **Actions** tab on `thenulldev/ipam`.
2. Pick the **Deploy IPAM** workflow on the left.
3. **Run workflow →** `main` branch, set `pinned_tag=<previous-sha>`,
set `deploy_only=true`, click **Run**.
4. The SSH step pulls that tag and runs `docker compose up -d` against it.
`/srv/ipam/deploy.sh` exits non-zero if `/healthz` does not return
`{"ok":true,"db":"up"}` within the retry window, so a bad rollback fails
loud instead of silently corrupting state.

For a manual rollback outside GitHub Actions (debug only):

```bash
ssh paperclip@paperclip.thenull.dev \
'cd /srv/ipam && IPAM_PINNED_TAG=<previous-sha> bash deploy.sh'
```

## Operational gotchas

- **First run after a fresh clone is slow.** Buildx + GitHub cache need a
cold-cache baseline; expect 6–10 min. Subsequent runs cache-hit and finish
in 2–3 min.
- **`latest` is a moving target.** Never pin `:latest` in an external
consumer — use the SHA tag. The `latest` tag only exists to give
operators a human-readable "what's running now" handle.
- **`/srv/ipam/deploy.sh` must exit non-zero on a failed healthcheck.**
The workflow treats non-zero exit as a deploy failure. NUL-225 (Relay)
authors that script with a 3×10s healthcheck gate.
- **SSH source-IP pinning.** Relay locks the authorized_keys entry to
`from="*.actions.githubusercontent.com"`. GitHub's IP ranges are public
but the wildcard host restriction is the strongest `from=` filter SSH
supports for GitHub-hosted runners; pinning by IP requires refreshing
the list as GitHub rotates ranges.
- **ghcr.io authentication choice.** Default path uses `GITHUB_TOKEN` with
`packages:write` (no extra secrets). To match `release.yml` exactly,
set `IPAM_DOCKER_USERNAME` / `IPAM_DOCKER_PASSWORD` and the workflow
picks them up automatically. The founder picks; the workflow works either
way.

## Acceptance checks (from NUL-226)

The acceptance criteria for NUL-226 are end-to-end and require the host-side
work from NUL-225 (Docker installed, nginx vhost + certbot, `/srv/ipam/deploy.sh`,
SSH deploy key) to be live. Until that lands, the workflow file is reviewable
but cannot be exercised:

| Check | Owner | Verifiable now? |
| ------------------------------------------------------------------------------ | --------- | -------------------------------- |
| `crane ls ghcr.io/thenulldev/ipam` shows the new SHA tag | build job | After first push (post-NUL-225). |
| Container on this host running that SHA | deploy job | After NUL-225 lands + first push. |
| `https://ipam.thenull.dev/healthz` returns `{"ok":true,"db":"up"}` within 5 min | end-to-end | After NUL-225 lands + first push. |
| `workflow_dispatch` can rerun deploy step alone with override SHA | workflow file | Verifiable by reading this file — the `deploy_only` + `pinned_tag` inputs are wired. |

## Related issues

- NUL-221 — parent ("Local Docker Deploy").
- NUL-222 — PM spec; full child tree and risk class.
- NUL-224 — `IPAM_HOST_PORT` env override (merged; required for host-port
avoidance on this box).
- NUL-225 — Relay's on-host work (Docker install, nginx vhost, certbot,
`/srv/ipam/deploy.sh`, SSH deploy key). **Must be merged before this
workflow can be exercised end-to-end.**
- NUL-227 — Sentinel's review checklist (`deploy-workflow` Block class).
- NUL-228 — Operator README for rollback (Quill).
Loading