offinfra-onboard: per-repo GHA->ghcr migration tool + f1-stream ghcr pull secret
All checks were successful
ci/woodpecker/push/default Pipeline was successful
ci/woodpecker/push/build-cli Pipeline was successful

ADR-0002 tracer bullet (infra#13), per Viktor's go-ahead. Idempotent
script: GitHub mirror repo (create/unarchive/visibility), GHA secrets
via gh, Forgejo push-mirror (sync_on_commit) + initial sync, Woodpecker
mirror registration, renders build.yml/deploy.yml from templates
(single-manifest provenance:false, svu semver to Forgejo, ghcr keep-10
retention, Slack notify-failure, manual-event deploy), removes the old
in-cluster build pipeline, commits on the Canonical side. f1-stream
stack gains the ghcr-credentials imagePullSecret (first consumer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-12 22:21:22 +00:00
parent 3138a0a040
commit baff3d7477
4 changed files with 344 additions and 0 deletions

205
scripts/offinfra-onboard Executable file
View file

@ -0,0 +1,205 @@
#!/usr/bin/env bash
# offinfra-onboard — migrate a Canonical (Forgejo) repo's image build to
# GitHub Actions → ghcr.io (ADR-0002, PRD infra#10). Idempotent: re-running
# skips every already-done step.
#
# What it does:
# 1. Ensures the GitHub mirror repo exists (right visibility; unarchives).
# 2. Sets GHA secrets (WOODPECKER_TOKEN, FORGEJO_GIT_TOKEN, SLACK_WEBHOOK).
# 3. Ensures the Forgejo push-mirror (sync_on_commit) + fires an initial sync.
# 4. Registers the mirror in Woodpecker (github forge) → deploy repo id.
# 5. Renders .github/workflows/build.yml + .woodpecker/deploy.yml into the
# clone, removes the old in-cluster build pipeline.
# 6. Commits on the FORGEJO side and pushes master (this fires the chain).
# 7. Flips the GitHub default branch to master once the mirror has synced.
#
# Usage:
# offinfra-onboard <name> --clone <path> --visibility private|public \
# --namespace <ns> --deploy "<deployment>=<container>[,<container>...]" \
# [--image <ghcr-image-name>] [--context <docker-context>] \
# [--test-steps <yaml-file>] [--dry-run]
#
# --deploy is repeatable. --test-steps points at a YAML fragment of extra
# steps for the lint-and-test job (indented for a `steps:` list); omitted =
# a no-op test job.
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
TEMPLATES="$SCRIPT_DIR/offinfra-templates"
GH_OWNER="ViktorBarzin"
FORGEJO_HOST="forgejo.viktorbarzin.me"
FORGEJO_LB="10.0.20.203"
WP_API="https://ci.viktorbarzin.me/api"
NAME=${1:?usage: offinfra-onboard <name> [flags]}; shift
CLONE="" VISIBILITY="" NAMESPACE="" IMAGE="" CONTEXT="." TEST_STEPS_FILE="" DRY_RUN=0
DEPLOYS=()
while [ $# -gt 0 ]; do
case "$1" in
--clone) CLONE=$2; shift 2;;
--visibility) VISIBILITY=$2; shift 2;;
--namespace) NAMESPACE=$2; shift 2;;
--image) IMAGE=$2; shift 2;;
--context) CONTEXT=$2; shift 2;;
--deploy) DEPLOYS+=("$2"); shift 2;;
--test-steps) TEST_STEPS_FILE=$2; shift 2;;
--dry-run) DRY_RUN=1; shift;;
*) echo "unknown flag: $1" >&2; exit 1;;
esac
done
IMAGE=${IMAGE:-$NAME}
[ -n "$CLONE" ] && [ -d "$CLONE/.git" ] || { echo "--clone must point at a git clone" >&2; exit 1; }
[ "$VISIBILITY" = "private" ] || [ "$VISIBILITY" = "public" ] || { echo "--visibility private|public" >&2; exit 1; }
[ -n "$NAMESPACE" ] || { echo "--namespace required" >&2; exit 1; }
[ ${#DEPLOYS[@]} -gt 0 ] || { echo "at least one --deploy required" >&2; exit 1; }
log() { printf '\033[1m[%s]\033[0m %s\n' "$NAME" "$*"; }
run() { if [ "$DRY_RUN" = 1 ]; then echo "DRY: $*"; else "$@"; fi; }
# --- credentials ---
export VAULT_ADDR=${VAULT_ADDR:-https://vault.viktorbarzin.me}
WP_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global)
SLACK_WEBHOOK=$(vault kv get -field=slack_webhook secret/ci/global)
GH_PAT=$(vault kv get -field=github_pat secret/viktor)
# Forgejo token: from the clone's forgejo remote URL (the documented contract)
FORGEJO_REMOTE=$(git -C "$CLONE" remote -v | awk -v h="$FORGEJO_HOST" '$2 ~ h && $3 == "(push)" {print $1; exit}')
[ -n "$FORGEJO_REMOTE" ] || { echo "no forgejo remote in $CLONE" >&2; exit 1; }
FORGEJO_TOKEN=$(git -C "$CLONE" remote get-url "$FORGEJO_REMOTE" | sed -n 's#https://[^:]*:\([^@]*\)@.*#\1#p')
[ -n "$FORGEJO_TOKEN" ] || { echo "could not extract forgejo token from remote URL" >&2; exit 1; }
FJ() { curl -sf --resolve "$FORGEJO_HOST:443:$FORGEJO_LB" -H "Authorization: token $FORGEJO_TOKEN" -H 'Content-Type: application/json' "$@"; }
WP() { curl -sf -H "Authorization: Bearer $WP_TOKEN" -H 'Content-Type: application/json' "$@"; }
# --- 1) GitHub mirror repo ---
if state=$(gh api "repos/$GH_OWNER/$NAME" --jq '{archived,private}' 2>/dev/null); then
archived=$(jq -r .archived <<<"$state"); private=$(jq -r .private <<<"$state")
if [ "$archived" = "true" ]; then
log "GitHub repo exists but is ARCHIVED — unarchiving"
run gh api -X PATCH "repos/$GH_OWNER/$NAME" -F archived=false >/dev/null
fi
want_private=$([ "$VISIBILITY" = private ] && echo true || echo false)
if [ "$private" != "$want_private" ]; then
log "setting visibility -> $VISIBILITY"
run gh api -X PATCH "repos/$GH_OWNER/$NAME" -F private="$want_private" >/dev/null
else
log "GitHub repo visibility already $VISIBILITY — SKIP"
fi
else
log "creating GitHub mirror repo ($VISIBILITY)"
run gh repo create "$GH_OWNER/$NAME" "--$VISIBILITY" \
--description "One-way mirror of forgejo viktor/$NAME — do NOT commit here (ADR-0002)" >/dev/null
fi
# --- 2) GHA secrets ---
log "setting GHA secrets (WOODPECKER_TOKEN, FORGEJO_GIT_TOKEN, SLACK_WEBHOOK)"
if [ "$DRY_RUN" = 0 ]; then
gh secret set WOODPECKER_TOKEN -R "$GH_OWNER/$NAME" --body "$WP_TOKEN"
gh secret set FORGEJO_GIT_TOKEN -R "$GH_OWNER/$NAME" --body "$FORGEJO_TOKEN"
gh secret set SLACK_WEBHOOK -R "$GH_OWNER/$NAME" --body "$SLACK_WEBHOOK"
fi
# --- 3) Forgejo push-mirror ---
mirrors=$(FJ "https://$FORGEJO_HOST/api/v1/repos/viktor/$NAME/push_mirrors" || echo '[]')
if printf '%s' "$mirrors" | jq -e --arg a "github.com/$GH_OWNER/$NAME" '.[] | select(.remote_address | contains($a))' >/dev/null 2>&1; then
log "push-mirror already configured — SKIP"
else
log "creating push-mirror -> github.com/$GH_OWNER/$NAME (sync_on_commit)"
run FJ -X POST "https://$FORGEJO_HOST/api/v1/repos/viktor/$NAME/push_mirrors" \
-d "{\"remote_address\":\"https://github.com/$GH_OWNER/$NAME.git\",\"remote_username\":\"$GH_OWNER\",\"remote_password\":$(jq -Rn --arg p "$GH_PAT" '$p'),\"interval\":\"8h0m0s\",\"sync_on_commit\":true}" >/dev/null
fi
log "firing initial mirror sync"
run FJ -X POST "https://$FORGEJO_HOST/api/v1/repos/viktor/$NAME/push_mirrors-sync" >/dev/null || true
# --- 4) Woodpecker registration (github forge) ---
WP_REPO_ID=$(WP "$WP_API/repos?perPage=100" | jq -r --arg n "$GH_OWNER/$NAME" '.[] | select(.full_name == $n) | .id' | head -1)
if [ -n "$WP_REPO_ID" ]; then
log "Woodpecker repo already registered (id=$WP_REPO_ID) — SKIP"
else
GH_REPO_ID=$(gh api "repos/$GH_OWNER/$NAME" --jq .id)
log "registering mirror in Woodpecker (forge_remote_id=$GH_REPO_ID)"
if [ "$DRY_RUN" = 0 ]; then
WP_REPO_ID=$(WP -X POST "$WP_API/repos?forge_remote_id=$GH_REPO_ID" | jq -r .id)
else
WP_REPO_ID="DRY"
fi
log "Woodpecker repo id = $WP_REPO_ID"
fi
# --- 5) Render workflow + deploy files into the clone ---
DEPLOY_CMDS=""
for d in "${DEPLOYS[@]}"; do
dep=${d%%=*}; containers=${d#*=}
setargs=""
IFS=',' read -ra cs <<<"$containers"
for c in "${cs[@]}"; do setargs="$setargs $c=\${IMAGE_NAME}:\${IMAGE_TAG}"; done
DEPLOY_CMDS="$DEPLOY_CMDS - \"kubectl -n $NAMESPACE set image deployment/$dep$setargs\"\n"
DEPLOY_CMDS="$DEPLOY_CMDS - \"kubectl -n $NAMESPACE rollout status deployment/$dep --timeout=300s\"\n"
done
if [ -n "$TEST_STEPS_FILE" ]; then
TEST_STEPS=$(cat "$TEST_STEPS_FILE")
else
TEST_STEPS=' - run: echo "no test steps configured"'
fi
export T_NAME=$NAME T_IMAGE=$IMAGE T_CONTEXT=$CONTEXT T_WPID=$WP_REPO_ID T_TEST="$TEST_STEPS" T_DEPLOY="$DEPLOY_CMDS"
render() { # $1=template $2=dest
python3 - "$1" "$2" <<'PYEOF'
import os, sys
src, dst = sys.argv[1], sys.argv[2]
s = open(src).read()
s = s.replace('{{NAME}}', os.environ['T_NAME'])
s = s.replace('{{IMAGE}}', os.environ['T_IMAGE'])
s = s.replace('{{CONTEXT}}', os.environ['T_CONTEXT'])
s = s.replace('{{WP_REPO_ID}}', os.environ['T_WPID'])
s = s.replace('{{TEST_STEPS}}', os.environ['T_TEST'])
s = s.replace('{{DEPLOY_CMDS}}', os.environ['T_DEPLOY'].replace('\\n', '\n').rstrip('\n'))
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, 'w').write(s)
PYEOF
}
log "rendering build.yml + deploy.yml"
if [ "$DRY_RUN" = 0 ]; then
render "$TEMPLATES/build.yml.tmpl" "$CLONE/.github/workflows/build.yml"
render "$TEMPLATES/deploy.yml.tmpl" "$CLONE/.woodpecker/deploy.yml"
fi
# --- 6) Remove old in-cluster build pipeline + commit on Forgejo side ---
cd "$CLONE"
OLD_REMOVED=""
for f in .woodpecker.yml .woodpecker/build.yml .woodpecker/build-fallback.yml; do
[ -f "$f" ] && { run git rm -q "$f"; OLD_REMOVED="$OLD_REMOVED $f"; }
done
run git add .github/workflows/build.yml .woodpecker/deploy.yml
if git diff --cached --quiet 2>/dev/null; then
log "no changes to commit — SKIP (already migrated)"
else
log "committing + pushing to forgejo master (this fires the chain)"
run git commit -q -m "ci: move image build off-infra to GHA -> ghcr (ADR-0002)
Generated by infra/scripts/offinfra-onboard: GHA builds+tests on the
GitHub mirror, pushes ghcr.io/viktorbarzin/$IMAGE, then triggers the
Woodpecker deploy (repo $WP_REPO_ID). Old in-cluster build pipeline
removed:$OLD_REMOVED
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
run git push "$FORGEJO_REMOTE" master
fi
# --- 7) GitHub default branch -> master (after mirror sync) ---
if [ "$DRY_RUN" = 0 ]; then
for i in $(seq 1 24); do
if gh api "repos/$GH_OWNER/$NAME/branches/master" >/dev/null 2>&1; then
cur=$(gh api "repos/$GH_OWNER/$NAME" --jq .default_branch)
[ "$cur" != "master" ] && gh api -X PATCH "repos/$GH_OWNER/$NAME" -F default_branch=master >/dev/null && log "default branch -> master"
break
fi
sleep 5
done
fi
log "DONE. Verify the chain:"
echo " - GHA run: gh run list -R $GH_OWNER/$NAME --limit 3"
echo " - ghcr tags: (token exchange) https://ghcr.io/v2/viktorbarzin/$IMAGE/tags/list"
echo " - WP deploy: $WP_API/repos/$WP_REPO_ID/pipelines"
echo " - rollout: kubectl -n $NAMESPACE get deploy"
echo " - pull secret: ensure the Deployment carries ghcr-credentials (Kyverno allowlist + stack imagePullSecrets)"

View file

@ -0,0 +1,115 @@
name: Build and Push
# Off-infra build (ADR-0002). Canonical repo is Forgejo viktor/{{NAME}}, which
# push-mirrors here; this workflow builds on GitHub-hosted runners, pushes the
# image to GHCR, then signals the Woodpecker deploy pipeline (repo {{WP_REPO_ID}})
# to roll the cluster — the homelab never sees build IO or registry pushes.
#
# Committed on the FORGEJO side (the mirror is one-way; commits made on GitHub
# are overwritten by the next sync). Generated by infra/scripts/offinfra-onboard.
on:
push:
branches: [master]
workflow_dispatch: {}
permissions:
contents: read
packages: write
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
{{TEST_STEPS}}
build:
needs: lint-and-test
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.sha }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history + tags so svu sees the last vX.Y.Z
fetch-tags: true
# Auto-semver (svu): tag-only, pushed to CANONICAL Forgejo (GitHub tags
# would be wiped by the next mirror sync). Best-effort: never blocks the build.
- name: Compute + tag semver (svu)
env:
FORGEJO_GIT_TOKEN: ${{ secrets.FORGEJO_GIT_TOKEN }}
run: |
set +e
git config user.email "ci@viktorbarzin.me"
git config user.name "{{NAME}}-ci"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
curl -sSL https://github.com/caarlos0/svu/releases/download/v3.4.1/svu_3.4.1_linux_amd64.tar.gz | tar -xz svu
CUR=$(./svu current 2>/dev/null)
NEXT=$(./svu next 2>/dev/null)
echo "svu current=[$CUR] next=[$NEXT]"
if [ -n "$NEXT" ] && [ "$NEXT" != "$CUR" ]; then
git tag "$NEXT" 2>/dev/null
git push "https://viktor:${FORGEJO_GIT_TOKEN}@forgejo.viktorbarzin.me/viktor/{{NAME}}.git" "$NEXT" && echo "pushed tag $NEXT to forgejo" || echo "tag push failed (non-blocking)"
fi
exit 0
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: {{CONTEXT}}
push: true
platforms: linux/amd64
# Single-manifest images (no provenance/SBOM attestation children) so
# registry retention can never orphan index children (ADR-0002).
provenance: false
tags: |
ghcr.io/viktorbarzin/{{IMAGE}}:${{ steps.meta.outputs.sha }}
ghcr.io/viktorbarzin/{{IMAGE}}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Keep the newest ~10 versions on ghcr (latest rides the newest one).
- name: ghcr retention (keep 10)
uses: actions/delete-package-versions@v5
continue-on-error: true
with:
package-name: {{IMAGE}}
package-type: container
min-versions-to-keep: 10
deploy:
needs: build
runs-on: ubuntu-latest
steps:
# Signal Woodpecker (repo {{WP_REPO_ID}} = ViktorBarzin/{{NAME}} mirror) to run
# .woodpecker/deploy.yml — kubectl set image in-cluster (agent SA is cluster-admin).
- name: Trigger Woodpecker deploy
run: |
for attempt in 1 2 3; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"https://ci.viktorbarzin.me/api/repos/{{WP_REPO_ID}}/pipelines" \
-H "Authorization: Bearer ${{ secrets.WOODPECKER_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"branch\":\"master\",\"variables\":{\"IMAGE_TAG\":\"${{ needs.build.outputs.image_tag }}\",\"IMAGE_NAME\":\"ghcr.io/viktorbarzin/{{IMAGE}}\"}}")
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
echo "Woodpecker deploy triggered (HTTP $STATUS)"; exit 0
fi
echo "Attempt $attempt failed (HTTP $STATUS), retrying in 30s..."; sleep 30
done
echo "Failed to trigger Woodpecker deploy after 3 attempts"; exit 1
notify-failure:
needs: [lint-and-test, build, deploy]
if: failure()
runs-on: ubuntu-latest
steps:
- name: Slack notify
run: |
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\":rotating_light: {{NAME}} off-infra build FAILED: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
"${{ secrets.SLACK_WEBHOOK }}" || true

View file

@ -0,0 +1,19 @@
# Auto-deploy, triggered ONLY by the GitHub Actions build POSTing to the
# Woodpecker API (manual event, with IMAGE_TAG + IMAGE_NAME) after a successful
# off-infra build+push to GHCR (ADR-0002). event:[manual] (NOT push) so the
# Forgejo->GitHub mirror's raw pushes don't fire a spurious deploy.
# The woodpecker-agent SA is cluster-admin — no kubeconfig needed.
# Generated by infra/scripts/offinfra-onboard.
when:
- event: manual
steps:
- name: check-vars
image: alpine
commands:
- "[ -n \"$IMAGE_TAG\" ] || (echo 'IMAGE_TAG not set — refusing to deploy'; exit 1)"
- name: deploy
image: bitnami/kubectl:latest
commands:
{{DEPLOY_CMDS}}

View file

@ -195,6 +195,11 @@ resource "kubernetes_deployment" "f1-stream" {
image_pull_secrets {
name = "registry-credentials"
}
# Private ghcr image (ADR-0002 off-infra builds) cloned into this
# namespace by the kyverno sync-ghcr-credentials allowlist policy.
image_pull_secrets {
name = "ghcr-credentials"
}
}
}
}