From 529d0a8956f22f15c86f72a3fb5664f02f1cd76a Mon Sep 17 00:00:00 2001 From: Nulled Agent Date: Thu, 23 Jul 2026 17:39:29 +0000 Subject: [PATCH 1/2] NUL-226: add Deploy IPAM workflow + repo-secrets operator doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/deploy.yml (named 'Deploy IPAM' to match the rollback runbook's reference): - on.push.branches:[main] + on.workflow_dispatch - jobs.build: buildx + ghcr.io login + build-push with tags type=sha,format=long and type=raw,value=latest (default-branch only), GHA cache. Login defaults to GITHUB_TOKEN with packages:write; falls back to IPAM_DOCKER_USERNAME/IPAM_DOCKER_PASSWORD if set, for parity with release.yml. - jobs.deploy: needs build, runs appleboy/ssh-action@v1, executes bash /srv/ipam/deploy.sh . pinned_tag + deploy_only inputs let workflow_dispatch rerun the deploy step alone for rollback. - concurrency: ipam-deploy-${{ github.ref }}, cancel-in-progress: false (never kill an in-flight deploy). docs/operations/deploy-workflow.md is the operator-facing write-up: what the workflow does, the full secrets table, how to wire up the deploy key, the rollback quick-reference, and the acceptance check status relative to NUL-225 (the on-host work that must land first). Risk class: deploy-workflow BLOCK. Sentinel review required; founder override required before Relay merges. This commit only adds files — no live state changed. --- .github/workflows/deploy.yml | 168 +++++++++++++++++++++++++++++ docs/operations/deploy-workflow.md | 148 +++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 docs/operations/deploy-workflow.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..4b9a552 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,168 @@ +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:. + 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: + name: SSH deploy to ${{ secrets.IPAM_DEPLOY_HOST || 'this host' }} + 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 `) 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. +# --------------------------------------------------------------------------- diff --git a/docs/operations/deploy-workflow.md b/docs/operations/deploy-workflow.md new file mode 100644 index 0000000..b36dc18 --- /dev/null +++ b/docs/operations/deploy-workflow.md @@ -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:` — the pin we deploy. + - `ghcr.io/thenulldev/ipam:latest` — rolling, only on default branch. +3. SSHes to this host (`paperclip@`) as `paperclip` using a + dedicated deploy key, and runs `bash /srv/ipam/deploy.sh `. + +`/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=` | Rollback: deploy `` 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=`, + 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= 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). From 905cb7a46013dbc035a507394743471e3a911c24 Mon Sep 17 00:00:00 2001 From: Nulled Agent Date: Thu, 23 Jul 2026 17:46:56 +0000 Subject: [PATCH 2/2] NUL-226: fix parse-time workflow error (secrets context in job.name) GitHub Actions rejected the workflow at parse time because 'secrets' is not in the allowed contexts for job.name (only github / inputs / matrix / needs / strategy / vars). actionlint caught it; the previous push resulted in a 0-second 'completed=failure' run with no jobs. Fix: drop the dynamic hostname interpolation from job.name; the deploy step itself still receives IPAM_DEPLOY_HOST via secrets, and the SSH failure (if any) will name the host in its own log. --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b9a552..48bcf0e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -103,7 +103,8 @@ jobs: cache-to: type=gha,mode=max deploy: - name: SSH deploy to ${{ secrets.IPAM_DEPLOY_HOST || 'this host' }} + # 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);