chore: bootstrap skills library — 19 skills + installer + CI auto-tag
Some checks failed
release / tag (push) Has been cancelled
Some checks failed
release / tag (push) Has been cancelled
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]
This commit is contained in:
487
gitea-ci/SKILL.md
Normal file
487
gitea-ci/SKILL.md
Normal file
@@ -0,0 +1,487 @@
|
||||
---
|
||||
name: gitea-ci
|
||||
description: 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.
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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.
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```go
|
||||
// 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.
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
|
||||
```bash
|
||||
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**:
|
||||
```yaml
|
||||
- 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:
|
||||
```yaml
|
||||
- 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**:
|
||||
```yaml
|
||||
- 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:
|
||||
```yaml
|
||||
- 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):
|
||||
```yaml
|
||||
- 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**:
|
||||
```yaml
|
||||
run: |
|
||||
python3 -c "
|
||||
import json ← column 0, YAML parser thinks block scalar ended
|
||||
d = {...}
|
||||
"
|
||||
```
|
||||
|
||||
**Right** — keep the python3 invocation on one line:
|
||||
```yaml
|
||||
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.
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `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
|
||||
Reference in New Issue
Block a user