Skip to content
Merged
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
147 changes: 15 additions & 132 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,65 +1,28 @@
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.
# Build and publish the rolling image consumed by Watchtower on the host.
# Watchtower polls ghcr.io/thenulldev/ipam:latest and restarts the container
# when that tag resolves to a new digest. The SHA tag remains for forensics and
# operator-selected rollback; GitHub Actions does not connect to the host.
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 }}
group: ipam-image-publish-${{ 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 }}
IMAGE_NAME: ghcr.io/thenulldev/ipam

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' }}
name: Build and push image
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:
Expand All @@ -70,100 +33,20 @@ jobs:
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 }}
registry: ghcr.io
username: ${{ github.actor }}
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
- name: Build and push SHA and rolling tags
uses: docker/build-push-action@v5
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).
tags: |
${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.IMAGE_NAME }}:latest
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.
# ---------------------------------------------------------------------------
Loading