Files
devsecops-ci-cd-gitea-3darch/references/harbor-auth.md
Oleg LukasonokandClaude Opus 4.8 8f46c3faa5 feat: devsecops-ci-cd-gitea skill — Gitea Actions → Harbor pipelines
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>
2026-08-13 00:10:03 +03:00

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/pull on project gondor-v1 can only act within gondor-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 login is required in addition to docker login; helm push uses Helm's own registry credential store and will return unauthorized without it, even right after a successful docker 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:

  1. 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
    
  2. Copy it onto the NAS to a host path the runner can mount, e.g. /mnt/<pool>/gitea-runner-harbor/config.json, then rm /tmp/harbor-docker-config.json.
  3. Wire it into the runner:
    • TrueNAS SCALE app: Edit → add a read-only Host Path Volume (…/gitea-runner-harbor → /opt/harbor-docker) and env DOCKER_CONFIG=/opt/harbor-docker.
    • Raw act_runner config.yaml: under container: add options: "-v /mnt/<pool>/gitea-runner-harbor:/opt/harbor-docker:ro -e DOCKER_CONFIG=/opt/harbor-docker", then restart.
  4. 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