Skills move to skills/<name>/SKILL.md and the repo gains both manifests, so it is now a marketplace hosting exactly one plugin whose root is the repo root (source: "."). Claude Code installs it from git; no symlinks involved. Why: install.sh wired the library into every consuming repo as an ABSOLUTE symlink to $CHECKOUT_DIR. The checkout moved three times, most recently when local-dev@1966d5a dropped the embedded .skills/ copy, and nothing migrated the links — 243 dangling links across 12 repos, hidden from git by the **/.claude/skills/ ignore rule. Worse, ~/dev spans two path roots (/Users/mathias on the iCloud-shared Macs, /home/mathias on koala), so an absolute link stored there is broken on one side by construction. Re-running the installer cannot fix that; only removing the mechanism can. install.sh keeps the harnesses with no plugin system and drops from 42 links per harness to one directory link, via the convergent ~/.agents/skills convention that Pi and OpenCode both read: ~/.agents/skills -> $CHECKOUT_DIR/skills ~/.config/crush/skills -> ~/.agents/skills ~/.config/opencode/skills -> ~/.agents/skills wire_claude_repo is deleted outright rather than gated behind a flag — on a mixed-root fleet it cannot be made correct. Antigravity and Vibe targets go too; both unused. CHECKOUT_DIR keeps its host-local default deliberately: pointing it into ~/dev would put a git working tree inside iCloud with two concurrent writers. Taskfile drops five near-identical wiring loops (they had already drifted) and delegates to install.sh. `task check` gains frontmatter validation, a stray-root-entry gate, and manifest/tag version parity — the loose secret-hygiene.md sat unloadable for months because nothing checked layout. Verified: claude plugin validate . passes clean; a probe skill loaded as skills:zzprobe through --plugin-dir; task check green at 42 skills. Refs: #23 Bump-Type: minor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
17 KiB
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 checkpassing locally before push - CI runs
task checkagain 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
maintriggers a run visible in Gitea repo → Actions checkjob passes (lint + test + vet green)buildjob produces an image in the local registry (curl localhost:5000/v2/<image>/tags/list)deployjob shows rollout success in pod logsmirrorjob shows "✓ Mirrored to GitHub" and the commit appears on GitHub- A PR (non-main push) runs
checkonly, skips build/deploy/mirror - Any project-specific secrets are set via API and verified with a secrets list call
Related Skills
tdd— for writing the tests that thecheckjob runsplanning— for breaking down the work before writing pipeline YAMLproblem-analysis— for debugging when a job fails in unexpected ways