blumeops/.forgejo/workflows/build-container-nix.yaml
Erich Blume ff63679efb Enable zot registry auth + wire CI credentials (#237)
## Summary

- Enable OIDC + API key authentication on zot registry with three-tier accessControl
  - `anonymousPolicy: ["read"]` — anyone can pull
  - `artifact-workloads` group: `["read", "create"]` — CI push, no overwrite/delete
  - `admins` group: `["read", "create", "update", "delete"]` — break-glass
- Wire both CI push paths (Dagger and Nix/skopeo) with `ZOT_CI_API_KEY` credentials
- Add `artifact-workloads` PolicyBinding in Authentik blueprint for zot app access
- Add `ZOT_CI_API_KEY` to Forgejo Actions secrets via existing ansible role

Completes the `wire-ci-registry-auth` and `harden-zot-registry` Mikado cards.

## Manual Deployment Steps (after merge)

1. Deploy Authentik blueprint: `argocd app sync authentik`
2. In Authentik admin UI: set a password for the `zot-ci` service account
3. Deploy zot config: `mise run provision-indri -- --tags zot`
4. Log in to `https://registry.ops.eblu.me` as `zot-ci` via OIDC → generate API key
5. Store API key in 1Password as `zot-ci-apikey` in blumeops vault
6. Sync Forgejo secrets: `mise run provision-indri -- --tags forgejo_actions_secrets`
7. Trigger a test container build to verify CI push
8. Verify anonymous pull: `curl -sf https://registry.ops.eblu.me/v2/_catalog`

## Uncertainties

- **Zot `accessControl` group matching with OIDC:** Groups from Authentik's `profile` scope claim should map to zot policy groups, but the exact claim-to-group matching needs runtime verification
- **`http.auth.apikey: true`:** This config key is documented but needs verification against the specific zot version built from source on indri
- **API key permissions:** Need to confirm zot API keys inherit the generating user's group for accessControl evaluation

## Test Plan

- [ ] `mise run provision-indri -- --check --diff --tags zot` shows expected config changes
- [ ] Anonymous pull works after deploy
- [ ] Unauthenticated push fails (401)
- [ ] OIDC browser login redirects to Authentik and back
- [ ] API key push works after key generation
- [ ] CI push succeeds with both Dagger and skopeo paths
- [ ] `mise run services-check` passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/237
2026-02-21 12:20:29 -08:00

145 lines
4.8 KiB
YAML

# Nix container build workflow
# Triggers on pushes to main that modify containers/*, or via manual dispatch.
# Detects which containers changed, builds from default.nix, and pushes via
# skopeo with commit-SHA-based tags: vX.Y.Z-<sha>-nix
name: Build Container (Nix)
on:
push:
branches: [main]
paths: ['containers/**']
workflow_dispatch:
inputs:
container:
description: 'Container name (directory under containers/)'
required: true
type: string
ref:
description: 'Commit SHA to build (defaults to current HEAD)'
required: false
type: string
jobs:
detect:
runs-on: nix-container-builder
outputs:
containers: ${{ steps.list.outputs.containers }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed containers
id: list
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CONTAINERS='["${{ inputs.container }}"]'
else
CONTAINERS=$(git diff --name-only HEAD~1 HEAD -- containers/ \
| cut -d/ -f2 | sort -u \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
fi
echo "containers=$CONTAINERS" >> "$GITHUB_OUTPUT"
echo "Containers to build: $CONTAINERS"
build:
needs: detect
if: needs.detect.outputs.containers != '[]'
runs-on: nix-container-builder
strategy:
matrix:
container: ${{ fromJson(needs.detect.outputs.containers) }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for default.nix
id: check
run: |
if [ -f "containers/${{ matrix.container }}/default.nix" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "No default.nix for ${{ matrix.container }} — skipping"
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Extract version and SHA
if: steps.check.outputs.exists == 'true'
id: meta
run: |
CONTAINER="${{ matrix.container }}"
NIX_FILE="containers/$CONTAINER/default.nix"
# Try extracting version = "..." from the nix file (e.g. ntfy)
VERSION=$(grep -m1 '^\s*version\s*=\s*"' "$NIX_FILE" \
| sed 's/.*"\(.*\)".*/\1/' || true)
# Fall back to CONTAINER_APP_VERSION from Dockerfile (e.g. nettest)
if [ -z "$VERSION" ] && [ -f "containers/$CONTAINER/Dockerfile" ]; then
VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \
"containers/$CONTAINER/Dockerfile" \
| sed 's/^ARG CONTAINER_APP_VERSION=//')
fi
# Last resort: nix eval for nixpkgs packages (e.g. authentik)
if [ -z "$VERSION" ]; then
VERSION=$(nix --extra-experimental-features "nix-command flakes" \
eval --raw "nixpkgs#${CONTAINER}.version")
fi
if [ -z "$VERSION" ]; then
echo "Error: Could not determine version for $CONTAINER"
exit 1
fi
REF="${{ inputs.ref }}"
if [ -z "$REF" ]; then
REF="${GITHUB_SHA}"
fi
SHORT_SHA=$(echo "$REF" | head -c 7)
# Ensure version starts with 'v'
case "$VERSION" in
v*) ;;
*) VERSION="v${VERSION}" ;;
esac
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION, SHA: $SHORT_SHA"
- name: Resolve nixpkgs
if: steps.check.outputs.exists == 'true'
id: nixpkgs
run: |
NIXPKGS_PATH=$(nix flake metadata nixpkgs --json | jq -r '.path')
echo "Resolved nixpkgs: $NIXPKGS_PATH"
echo "path=$NIXPKGS_PATH" >> "$GITHUB_OUTPUT"
- name: Build with nix
if: steps.check.outputs.exists == 'true'
env:
NIX_PATH: "nixpkgs=${{ steps.nixpkgs.outputs.path }}"
run: |
echo "Building containers/${{ matrix.container }}/default.nix"
echo "NIX_PATH=$NIX_PATH"
nix-build "containers/${{ matrix.container }}/default.nix" -o result
echo "Build complete: $(readlink result)"
- name: Push to registry
if: steps.check.outputs.exists == 'true'
env:
ZOT_CI_API_KEY: ${{ secrets.ZOT_CI_API_KEY }}
run: |
CONTAINER="${{ matrix.container }}"
VERSION="${{ steps.meta.outputs.version }}"
SHORT_SHA="${{ steps.meta.outputs.sha }}"
IMAGE="registry.ops.eblu.me/blumeops/$CONTAINER:${VERSION}-${SHORT_SHA}-nix"
echo "Pushing to $IMAGE"
skopeo copy \
--dest-creds="zot-ci:$ZOT_CI_API_KEY" \
"docker-archive:result" \
"docker://$IMAGE"
echo "Push complete: $IMAGE"