diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..678669b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,127 @@ +name: Build and Push + +# Off-infra build (ADR-0002). Canonical repo is Forgejo viktor/payslip-ingest, which +# push-mirrors here; this workflow builds on GitHub-hosted runners, pushes the +# image to GHCR, then signals the Woodpecker deploy pipeline (repo 205) +# 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 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + # Derived from the repo's tooling (pyproject: ruff + strict mypy + pytest; + # the old .woodpecker.yml had build+deploy only). Verified green locally + # before migration: ruff clean, mypy 27 files clean, 91 tests passing. + - name: Lint + type-check + test + run: | + pip install --no-cache-dir "poetry==1.8.4" + poetry install --no-interaction --no-root + poetry run ruff check . + poetry run mypy payslip_ingest tests + poetry run pytest -q + + 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 "payslip-ingest-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/payslip-ingest.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: . + 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/payslip-ingest:${{ steps.meta.outputs.sha }} + ghcr.io/viktorbarzin/payslip-ingest: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: payslip-ingest + package-type: container + min-versions-to-keep: 10 + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + # Signal Woodpecker (repo 205 = ViktorBarzin/payslip-ingest 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/205/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/payslip-ingest\"}}") + 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: payslip-ingest off-infra build FAILED: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ + "${{ secrets.SLACK_WEBHOOK }}" || true diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index 6b00b90..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,49 +0,0 @@ -when: - event: push - -clone: - git: - image: woodpeckerci/plugin-git - settings: - attempts: 5 - backoff: 10s - -steps: - - name: build-and-push - image: woodpeckerci/plugin-docker-buildx - settings: - # Phase 4 of forgejo-registry-consolidation 2026-05-07 — - # registry.viktorbarzin.me decommissioned. Forgejo is the only target. - repo: - - forgejo.viktorbarzin.me/viktor/payslip-ingest - logins: - - registry: forgejo.viktorbarzin.me - username: - from_secret: forgejo_user - password: - from_secret: forgejo_push_token - dockerfile: Dockerfile - context: . - platforms: - - linux/amd64 - tags: - - "latest" - - "${CI_COMMIT_SHA:0:8}" - - # We build the image, so we drive the rollout too — atomic + deterministic, - # no wait for Keel's hourly poll and no risk of Keel resolving :latest to a - # stale concrete tag. Keel stays enrolled in parallel as a redundant net - # (it finds the SHA already running → no-op). set image on a NEW :SHA always - # changes the pod template → guaranteed rollout, and bootstraps off any - # legacy pinned tag with no manual step. Sets the alembic-migrate init - # container too — it shares the image so its tag must move in lockstep. - - name: deploy - image: bitnami/kubectl:latest - depends_on: - - build-and-push - when: - branch: master - event: [push, manual] - commands: - - "kubectl set image deployment/payslip-ingest payslip-ingest=forgejo.viktorbarzin.me/viktor/payslip-ingest:${CI_COMMIT_SHA:0:8} alembic-migrate=forgejo.viktorbarzin.me/viktor/payslip-ingest:${CI_COMMIT_SHA:0:8} -n payslip-ingest" - - "kubectl rollout status deployment/payslip-ingest -n payslip-ingest --timeout=300s" diff --git a/.woodpecker/deploy.yml b/.woodpecker/deploy.yml new file mode 100644 index 0000000..67404e8 --- /dev/null +++ b/.woodpecker/deploy.yml @@ -0,0 +1,20 @@ +# 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: + - "kubectl -n payslip-ingest set image deployment/payslip-ingest payslip-ingest=${IMAGE_NAME}:${IMAGE_TAG} alembic-migrate=${IMAGE_NAME}:${IMAGE_TAG}" + - "kubectl -n payslip-ingest rollout status deployment/payslip-ingest --timeout=300s"