Generate and maintain Gitea Actions CI/CD pipelines that build & publish Docker images (applications-backend/*, applications-frontend/*) and OCI Helm charts (helm-charts/*) from a pnpm + Turborepo monorepo to a Harbor registry, authenticated by a project robot account via Gitea org secret + global vars. - SKILL.md: auth model, convention-driven discovery, publish gating, gotchas - references/harbor-auth.md: docker/helm login, robot accounts, secret/var taxonomy, bake-into-runner alternative, troubleshooting - references/pipeline-workflow.md: jobs, discovery loops, tagging, turbo prune - references/conventions.md: Dockerfile/chart locations, image & chart naming - assets/workflows/ci-cd.yaml: ready-to-drop .gitea/workflows pipeline Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.4 KiB
Harbor authentication for Gitea Actions
How a pipeline authenticates to the Harbor registry to pull base images and push built images/charts — the robot account model, the exact Gitea secret/variable layout, the login commands, and the "bake into the runner" alternative.
1. Robot accounts — why, and what they look like
CI must never push with a person's Harbor credentials. Harbor's answer is a robot account: a non-human account scoped to a project, with a fixed long username and a generated secret.
- Username (public, not a secret):
robot$<project>+<name>— e.g.robot$gondor-v1+gitea-actions-v1. The$is part of the literal string, not shell syntax. - Secret (sensitive): a generated token shown once, at creation. Harbor never returns it again on a GET. If you lose it, you regenerate (which invalidates the old one).
- Scope: project-level. A robot with
push/pullon projectgondor-v1can only act withingondor-v1. To publish images/charts to a project, the robot must have push there.
In this platform the robot is created and cached by the palantir harbor module — do not
create robots by hand in the UI:
# from the palantir-v1 repo, with KUBECONFIG set
task harbor:project:assure-one -- --name=gondor-v1 --public=false
task harbor:robot:gitea-actions-v1:assure-one # pull,push -> .harbor/gondor-v1-gitea-actions-v1.yml
task harbor:robot:gondor-v1:assure-one # pull -> used by the cluster's imagePullSecret
The secret is cached in git-ignored .harbor/<project>-<robot>.yml. Read it with:
yq '.secret' .harbor/gondor-v1-gitea-actions-v1.yml
There are two robots with different jobs — do not mix them up:
| Robot | Permissions | Used by | Credential lands in |
|---|---|---|---|
gitea-actions-v1 |
pull, push | Gitea Actions (this skill) | Gitea org secret (below) |
gondor-v1 |
pull only | cluster imagePullSecret (Argo CD / workloads) |
Bitwarden → ESO ClusterExternalSecret |
2. The Gitea secret/variable layout
Gitea Actions has two stores — variables (plaintext, visible) and secrets (masked) — each available at repo, org, and (variables only) global/admin level:
| Repo | Org / User | Global (admin) | |
|---|---|---|---|
| Variables | ✅ | ✅ | ✅ …/-/admin/actions/variables |
| Secrets | ✅ | ✅ | ❌ no global level |
Because the username and address are not secret, put them in global variables — set once, seen by every organisation. The robot secret is the only value that must be a secret, and since there is no global secret, it is an org secret (repeat per org you onboard).
Global variables — Site Administration → Actions → Variables, once:
| Name | Value |
|---|---|
HL_V1_HARBOR_ADDRESS |
harbor-v1.apps.lego-cloud.eu |
HL_V1_HARBOR_ROBOT_GITEA_ACTIONS_V1_USERNAME |
robot$gondor-v1+gitea-actions-v1 |
Optionally also global: HL_V1_HARBOR_PROJECT = gondor-v1 (the push target; the template
defaults to gondor-v1 if unset).
Org secret — https://gitea.lego-cloud.eu/org/<ORG>/settings/actions/secrets, per org:
| Name | Value |
|---|---|
HL_V1_HARBOR_ROBOT_GITEA_ACTIONS_V1_SECRET |
output of yq '.secret' .harbor/gondor-v1-gitea-actions-v1.yml |
Referencing them in a workflow:
env:
HARBOR_ADDRESS: ${{ vars.HL_V1_HARBOR_ADDRESS }}
HARBOR_USERNAME: ${{ vars.HL_V1_HARBOR_ROBOT_GITEA_ACTIONS_V1_USERNAME }}
HARBOR_SECRET: ${{ secrets.HL_V1_HARBOR_ROBOT_GITEA_ACTIONS_V1_SECRET }}
HARBOR_PROJECT: ${{ vars.HL_V1_HARBOR_PROJECT || 'gondor-v1' }}
3. Logging in — docker and helm are separate credential stores
# container images
echo "${HARBOR_SECRET}" | docker login "${HARBOR_ADDRESS}" -u "${HARBOR_USERNAME}" --password-stdin
# OCI helm charts — a DIFFERENT store; docker login does not cover it
echo "${HARBOR_SECRET}" | helm registry login "${HARBOR_ADDRESS}" -u "${HARBOR_USERNAME}" --password-stdin
- Always
--password-stdin. A-p <secret>argument leaks into the process list and runner logs. helm registry loginis required in addition todocker login;helm pushuses Helm's own registry credential store and will returnunauthorizedwithout it, even right after a successfuldocker login.- Log out at the end of the job if the runner is long-lived and shared:
docker logout "${HARBOR_ADDRESS}"; helm registry logout "${HARBOR_ADDRESS}".
Rotation. Re-run the palantir robot task to regenerate the secret (.harbor/… is
re-cached), then update the Gitea org secret with the new value. The username and address
do not change, so the global variables stay put.
4. Alternative — bake the login into the runner
If attaching an org secret to every organisation is more chore than you want, pre-authenticate the runner itself so every job — in every org — can pull/push Harbor with no Gitea secret referenced.
Because act_runner runs each job in a container that talks to the host Docker daemon over the
mounted socket, docker inside a job reads its own config, not the host's. So seed a
config.json into every job container:
- Build the auth file locally (secret never printed to the terminal):
U='robot$gondor-v1+gitea-actions-v1' S=$(yq '.secret' .harbor/gondor-v1-gitea-actions-v1.yml) AUTH=$(printf '%s:%s' "$U" "$S" | base64 | tr -d '\n') umask 077 printf '{ "auths": { "harbor-v1.apps.lego-cloud.eu": { "auth": "%s" } } }\n' "$AUTH" \ > /tmp/harbor-docker-config.json - Copy it onto the NAS to a host path the runner can mount, e.g.
/mnt/<pool>/gitea-runner-harbor/config.json, thenrm /tmp/harbor-docker-config.json. - Wire it into the runner:
- TrueNAS SCALE app: Edit → add a read-only Host Path Volume
(
…/gitea-runner-harbor→/opt/harbor-docker) and envDOCKER_CONFIG=/opt/harbor-docker. - Raw act_runner
config.yaml: undercontainer:addoptions: "-v /mnt/<pool>/gitea-runner-harbor:/opt/harbor-docker:ro -e DOCKER_CONFIG=/opt/harbor-docker", then restart.
- TrueNAS SCALE app: Edit → add a read-only Host Path Volume
(
- Jobs now run
docker pull/push harbor-v1.apps.lego-cloud.eu/...with no secret reference.
Trade-off. Zero per-org setup and it covers all orgs, but the Harbor secret is duplicated
onto the NAS and rotating it means rewriting that file. It also does not cover helm push
(Helm has its own store) — for charts you still helm registry login in the job, or add a
second baked Helm credential. Prefer the org-secret model (§2) when you want one source of
truth and central rotation.
5. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
docker login → unauthorized: unauthorized to access repository |
wrong robot secret, or robot lacks push on the project | re-read .harbor/…, confirm the org secret matches; confirm the robot has push on HARBOR_PROJECT |
helm push → unauthorized right after a good docker login |
missing helm registry login |
add the helm login step |
docker: not found in the job |
runner label maps to an image without the docker CLI | pick a label/image that has docker (and helm) |
denied: requested access to the resource is denied on push |
pushing to a project the robot can't write, or a typo in HARBOR_PROJECT |
set HARBOR_PROJECT to the robot's project |
| login works locally, fails in CI | job can't reach Harbor (edge/DNS) or cert not trusted | confirm the runner resolves and trusts harbor-v1.apps.lego-cloud.eu |