Files
skills/gitea-ci/SKILL.md
Mathias d6a71e370e
Some checks failed
release / tag (push) Has been cancelled
chore: bootstrap skills library — 19 skills + installer + CI auto-tag
Phase 1 of mathias/skills extraction (infra#62 Track D — homelab
next-step plan addendum). Imports ~/dev/.skills/ verbatim (19 skill
dirs + SKILLS_INDEX.md) and adds the installation surface:

- Taskfile.yml — install / update / list / release / check targets
- install.sh — bootstrap installer for hosts without Task. Idempotent
  symlink wirer; default checkout at ~/.local/share/skills/ on every
  host; SKILLS_REF env var pins a tag (default: main).
- .gitea/workflows/release.yml — auto-tag every push to main by
  Bump-Type footer (major/minor/patch, default patch). Skipped when
  commit contains [skip-release].
- README — usage, versioning, contribution flow, secret-hygiene rule.

Phase 1 wires Claude Code only (~/.claude/skills/<name> global +
<repo>/.claude/skills/<name> per-repo). Phase 2 adds Crush, opencode,
antigravity, and gitea-resident agents (cobalt-dingo, agentsquad)
once their skill conventions are researched.

Public repo, markdown-only — no secrets, no client names. Verified
via pre-push grep before initial push.

[skip-release]
2026-05-24 14:59:54 +02:00

17 KiB
Raw Blame History

name, description
name description
gitea-ci End-to-end Gitea CI/CD setup with self-hosted act_runner, quality gate pipeline, buildah image build, k3s deploy, and GitHub mirror. Covers act_runner-specific gotchas that are not documented anywhere.

Gitea CI/CD Pipeline Setup

Overview

This skill sets up a complete CI/CD pipeline on Gitea with a self-hosted runner. It covers the full journey from a bare repo to a working pipeline with: lint/test gate, OCI image build, k3s deploy, and GitHub mirror. It also captures non-obvious act_runner quirks that will burn you if you don't know them.

Load this skill when starting a new project that needs CI, or when debugging a broken Gitea Actions pipeline.

When to Use

  • Setting up CI for a new project on this infra (Gitea + koala/iguana/flamingo)
  • Debugging a Gitea Actions pipeline that silently fails or behaves unexpectedly
  • Migrating an existing project to Gitea Actions from another CI system

Phase 0: Gather Context

Before writing any YAML, collect the following. Use AskUserQuestion for anything not obvious from the project:

Item Where to find it Why it matters
Gitea repo URL git remote -v Base URL for API calls
Runner machine CLAUDE.md / infra notes Which machine runs jobs
Primary language go.mod, package.json, etc. Drives the toolchain steps
Container registry Ask or check existing config localhost:5000 on koala, or external
k3s namespace k8s/ manifests or ask Deploy target
GitHub mirror repo Ask Target for the mirror job
Image name Ask or derive from repo name Used in buildah + k3s steps

Check the project's CLAUDE.md for infra notes first — often this answers most questions without needing to ask.


Phase 1: Verify Runner Is Live

Before writing the workflow, confirm the runner is registered and healthy.

# Check registered runners via Gitea API
curl -s "https://<gitea-host>/api/v1/repos/<owner>/<repo>/actions/runners" \
  -H "Authorization: token $GITEA_TOKEN" | python3 -m json.tool

If no runners appear, the runner is not registered. On this infra, koala runs act_runner. Check with:

ssh koala "systemctl status act_runner"
# or if running as a process:
ssh koala "pgrep -a act_runner"

act_runner config is typically at ~/.config/act_runner/ or wherever it was installed. Registration command:

act_runner register \
  --instance https://<gitea-host> \
  --token <runner-registration-token> \
  --name koala \
  --labels self-hosted

The registration token is in Gitea: Settings → Actions → Runners → Create Runner.


Phase 2: Write the Workflow

The standard pipeline has 4-5 jobs. Copy the template below and fill in the env.IMAGE value and any project-specific toolchain steps.

name: CI

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
    branches: [main]

env:
  IMAGE: <project-name>   # ← change this

jobs:
  # ── 1. Quality gate ─────────────────────────────────────────────────────────
  check:
    name: Lint / Test / Vet
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache: false   # self-hosted: Go cache persists on disk between runs

      - name: Verify toolchain
        run: |
          go version
          task --version
          govulncheck -version 2>&1 || true

      - name: Install golangci-lint
        run: |
          curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \
            | sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
          golangci-lint --version

      - name: Run checks
        run: task check

  # ── 2. Build image ──────────────────────────────────────────────────────────
  build:
    name: Build & Import
    needs: check
    runs-on: self-hosted
    if: github.event_name != 'pull_request'
    outputs:
      image-tag: ${{ steps.meta.outputs.sha-tag }}
    steps:
      - uses: actions/checkout@v4

      - name: Derive image tags
        id: meta
        run: |
          SHA=$(git rev-parse --short HEAD)
          echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
          REF="${{ github.ref }}"
          if [[ "$REF" == refs/tags/v* ]]; then
            echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
          fi

      - name: Build and push to local registry
        run: |
          REGISTRY="localhost:5000"
          REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
          buildah build \
            --label "org.opencontainers.image.revision=${{ github.sha }}" \
            --label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
            -t ${REF} \
            -t ${REGISTRY}/${{ env.IMAGE }}:latest \
            .
          buildah push --tls-verify=false ${REF}
          buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
          [[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
            buildah push --tls-verify=false \
              ${REF} \
              ${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
          echo "✓ Image pushed to ${REF}"

      - name: Smoke test
        run: |
          REGISTRY="localhost:5000"
          REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
          CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
          sudo k3s ctr images pull --plain-http ${REF}
          OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /<binary-name> 2>&1 || true)
          sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
          echo "$OUTPUT" | grep -q "<project-name>" \
            && echo "✓ Smoke test passed" \
            || echo "⚠ Smoke test inconclusive: $OUTPUT"

  # ── 3. Deploy to k3s ────────────────────────────────────────────────────────
  deploy:
    name: Deploy to staging (k3s)
    needs: build
    runs-on: self-hosted
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Apply manifests and update image
        env:
          IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
        run: |
          kubectl apply -f k8s/namespace.yaml
          kubectl apply -f k8s/deployment.yaml
          kubectl set image deployment/<project-name> \
            <project-name>=localhost:5000/${{ env.IMAGE }}:${IMAGE_TAG} \
            --namespace <project-name>

      - name: Verify rollout
        run: |
          kubectl rollout status deployment/<project-name> \
            --namespace <project-name> \
            --timeout=120s \
          || {
            echo "── pod status ──"
            kubectl get pods -n <project-name> -o wide
            echo "── pod events ──"
            kubectl get events -n <project-name> --sort-by='.lastTimestamp' | tail -20
            exit 1
          }

  # ── 4. Mirror to GitHub ─────────────────────────────────────────────────────
  mirror:
    name: Mirror to GitHub
    needs: deploy
    runs-on: self-hosted
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Push to GitHub
        run: |
          mkdir -p ~/.ssh
          echo '${{ secrets.GH_DEPLOY_KEY }}' > ~/.ssh/id_rsa_gh_mirror
          chmod 600 ~/.ssh/id_rsa_gh_mirror
          ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
          GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
            git push git@github.com:<gh-org>/<repo>.git HEAD:main
          rm ~/.ssh/id_rsa_gh_mirror
          echo "✓ Mirrored to GitHub"

Replace all <placeholders> before committing.


Trunk-Based Development and CI

This infra uses TBD. CI on every push to main is the quality gate — not branch protection.

What this means in practice

  • Commit directly to main for all solo and single-agent work
  • Each commit: one logical change + task check passing locally before push
  • CI runs task check again on push — if it was green locally it will be green in CI
  • No PRs, no feature branches, no squash-merges for normal work

The pre-push ritual

# In project root before every push:
task check && git push origin main

Wire this into your shell or a git pre-push hook so it's automatic.

When CI catches something you missed locally

Fix forward — commit the fix directly to main. Do not revert unless the broken commit introduced a data migration or irreversible infrastructure change.

Feature flags instead of feature branches

If a feature is too large for one session, hide it behind a build tag or a config flag until ready:

// cmd/server/main.go
if os.Getenv("ENABLE_EXPERIMENTAL_FEATURE") == "true" {
    server.RegisterExperimentalRoutes(mux)
}

Commit the hidden feature incrementally. Activate it when complete.

When to break the rule

  • Parallel agents on the same repo: short-lived agent/<description> branch, merge to main inside the same session.
  • External contributor or client four-eyes requirement: PR flow, document the reason in PROJECT.md.

Phase 3: Set Up GitHub Mirror

The mirror job needs an SSH deploy key with write access to the GitHub repo.

# On your local machine:
ssh-keygen -t ed25519 -C "gitea-mirror-<project>" -f /tmp/gh_mirror_key -N ""
cat /tmp/gh_mirror_key.pub   # add this to GitHub repo → Settings → Deploy Keys (Allow write)
cat /tmp/gh_mirror_key       # add this to Gitea repo → Settings → Secrets → GH_DEPLOY_KEY
rm /tmp/gh_mirror_key /tmp/gh_mirror_key.pub

The private key goes in Gitea as the GH_DEPLOY_KEY secret. The public key goes in GitHub as a deploy key with write access.


Phase 4: Add Project Secrets

Set secrets via the Gitea API — faster than the UI and scriptable:

# Template:
curl -s -X PUT \
  "https://<gitea-host>/api/v1/repos/<owner>/<repo>/actions/secrets/<SECRET_NAME>" \
  -H "Authorization: token $GITEA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data":"<secret-value>"}' \
  -w "\nHTTP %{http_code}"
# Expected: HTTP 204

# Verify secrets are set:
curl -s "https://<gitea-host>/api/v1/repos/<owner>/<repo>/actions/secrets" \
  -H "Authorization: token $GITEA_TOKEN" | python3 -m json.tool

Required secrets for the baseline pipeline: GH_DEPLOY_KEY.

Any project-specific secrets (API keys, tokens, credentials) are added the same way.


Phase 5: Debug Failing Runs

When a run fails, use the Gitea API to get logs without opening a browser.

GITEA_HOST="https://<gitea-host>"
REPO="<owner>/<repo>"
TOKEN="$GITEA_TOKEN"

# List recent runs
curl -s "$GITEA_HOST/api/v1/repos/$REPO/actions/runs?limit=5" \
  -H "Authorization: token $TOKEN" | python3 -c "
import json,sys
for r in json.load(sys.stdin)['workflow_runs']:
    print(f'#{r[\"id\"]} {r[\"status\"]:12} {r.get(\"conclusion\",\"\"):10} {r[\"display_title\"][:50]}')
"

# List jobs for a run (replace RUN_ID)
curl -s "$GITEA_HOST/api/v1/repos/$REPO/actions/runs/<RUN_ID>/jobs" \
  -H "Authorization: token $TOKEN" | python3 -c "
import json,sys
for j in json.load(sys.stdin)['jobs']:
    print(f'Job {j[\"id\"]}: {j[\"name\"]:30} | {j[\"status\"]:10} | {j.get(\"conclusion\",\"\")}')
"

# Get full log for a job (replace JOB_ID)
curl -s "$GITEA_HOST/api/v1/repos/$REPO/actions/jobs/<JOB_ID>/logs" \
  -H "Authorization: token $TOKEN" | grep -E "ERROR|error|FAIL|undefined|cannot" | tail -30

⚠ act_runner Gotchas

These are non-obvious bugs and sharp edges that will silently break your pipeline. Read all of them.

1. Steps with env: blocks that use secret expressions are silently skipped

Symptom: A step produces no log output at all — not even its step header (⭐ Run Main ...). Subsequent steps fail as if the skipped step never ran.

Cause: In act_runner v0.4.x, steps that have an env: block containing ${{ secrets.* }} expressions are silently dropped before execution.

Wrong:

- name: Write config
  env:
    API_KEY: ${{ secrets.API_KEY }}
  run: |
    echo "KEY=$API_KEY" > config.env

Right — inline the expression directly in the run: script:

- name: Write config
  run: |
    echo 'KEY=${{ secrets.API_KEY }}' > config.env

act_runner evaluates ${{ }} expressions before passing the script to bash. The values are redacted in log output. No env: block needed.

2. Heredocs in run: | blocks — the indented EOF trap

Symptom: A file is written with 10-100× more content than expected. JSON files fail to parse. The step appears to succeed.

Cause: In a YAML literal block scalar (run: |), all content is at the block's indentation level. If you indent your heredoc content (to look neat), the EOF terminator also gets indented. Bash requires the terminator to be at column 0 — an indented EOF is NOT recognized, so the heredoc consumes everything that follows until end-of-script.

Wrong:

      - name: Write file
        run: |
          cat > output.json <<EOF
          {
            "key": "value"
          }
          EOF          ← indented, NOT recognized as terminator
          chmod 600 output.json

The file ends up containing the JSON, the literal string EOF, and chmod 600 output.json.

Right — avoid heredocs entirely. Use echo for simple lines, printf for single-line formatted output:

      - name: Write file
        run: |
          echo '{"key":"${{ secrets.SOME_VALUE }}"}' > output.json
          chmod 600 output.json

For JSON with dynamic values, use python3 as a one-liner (but see gotcha #3):

      - name: Write token file
        run: |
          python3 -c "import json; open('token.json','w').write(json.dumps({'key':'${{ secrets.TOKEN }}','type':'bearer'}))"

3. Multiline python3 -c breaks YAML block scalar parsing

Symptom: The workflow file is pushed but no CI run appears — Gitea silently rejects the workflow YAML.

Cause: Unindented code in a multiline python3 -c "..." block (with line breaks inside the YAML run: |) is at column 0, which the YAML parser interprets as ending the block scalar and starting a new YAML key.

Wrong:

        run: |
          python3 -c "
import json          ← column 0, YAML parser thinks block scalar ended
d = {...}
"

Right — keep the python3 invocation on one line:

        run: |
          python3 -c "import json; d = {...}; open('f','w').write(json.dumps(d))"

If the one-liner is too long, write a small .py file in a previous step and execute it.

4. Validate YAML locally before pushing

Gitea silently ignores workflows with YAML parse errors — no run appears, no error message.

python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/ci.yml').read()); print('YAML valid')"

Run this before every push that touches the workflow file.

5. Commit generated files instead of gitignoring them

Symptom: Lint/build fails in CI with undefined: SomeGeneratedType even though it works locally.

Cause: Code generation tools (templ, sqlc, wire, protoc, etc.) produce _generated.go or _templ.go files that are often in .gitignore. CI runners don't have the generator installed and can't regenerate them.

Fix: Remove the generated files from .gitignore and commit them. They are deterministic, diffable Go source — committing them is the standard practice for projects that don't want to install generators in CI.

If you need generators in CI anyway (e.g., for proto changes), add a generation step BEFORE lint in the check job and install the tool explicitly.

6. errcheck will flag fmt.Sscanf and similar "can't fail in practice" calls

Parsers that convert strings to numbers (e.g., parsing API responses where you know the format) will trigger errcheck. Suppress with blank assignment:

// before
fmt.Sscanf(s, "%d", &n)

// after — communicates intent and satisfies errcheck
_, _ = fmt.Sscanf(s, "%d", &n)

Verification Checklist

Before marking CI setup complete:

  • python3 -c "import yaml; yaml.safe_load(...)" passes on the workflow YAML
  • Runner appears as active in Gitea repo → Settings → Actions → Runners
  • A push to main triggers a run visible in Gitea repo → Actions
  • check job passes (lint + test + vet green)
  • build job produces an image in the local registry (curl localhost:5000/v2/<image>/tags/list)
  • deploy job shows rollout success in pod logs
  • mirror job shows "✓ Mirrored to GitHub" and the commit appears on GitHub
  • A PR (non-main push) runs check only, skips build/deploy/mirror
  • Any project-specific secrets are set via API and verified with a secrets list call

  • tdd — for writing the tests that the check job runs
  • planning — for breaking down the work before writing pipeline YAML
  • problem-analysis — for debugging when a job fails in unexpected ways