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.0 KiB
The pipeline, explained
Anatomy of the generated Gitea Actions workflow (assets/workflows/ci-cd.yaml) — the jobs, the
discovery loops, tag derivation, publish gating, and the turbo prune build model. Read this
when editing the workflow or debugging a run.
Jobs
The workflow has two jobs; charts wait for images so a run doesn't publish a chart for an image that failed to build:
build-and-push-images ─▶ package-and-push-charts (needs: build-and-push-images)
Both begin the same way: actions/checkout, then a login step (§Harbor login), then a
single discover + act shell step that loops over the convention paths.
Why one shell step per job instead of an Actions matrix: discovery is dynamic (the set of
apps changes as the monorepo grows) and a shell loop needs no round-trip to compute a matrix,
no dependency on a marketplace action, and prints a clean per-item log. If you later want
parallelism across apps, split discovery into a prepare job that emits a matrix via
GITHUB_OUTPUT — but keep the loop as the default; it is the least fragile.
Triggers and publish gating
on:
push:
branches: [test, main]
tags: ["v*"]
pull_request:
branches: [test, main]
A single computed flag decides push vs. build-only. Do not remove it — a PR must prove the artifact builds without publishing anything:
# PUBLISH=true only on a push to a release branch or a tag; never on a pull_request
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
PUBLISH=false
elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then
PUBLISH=true
elif [ "${GITHUB_REF_NAME}" = "test" ] || [ "${GITHUB_REF_NAME}" = "main" ]; then
PUBLISH=true
else
PUBLISH=false
fi
| Event | Images | Charts |
|---|---|---|
pull_request |
docker build (no push) |
helm lint + helm package (no push) |
push to test / main |
build and push :<sha> + :latest |
package and push |
tag v* |
build and push :<tag> |
package and push (optionally --version/--app-version = tag) |
Tagging
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}" # e.g. v1.4.2
else
TAG="$(git rev-parse --short HEAD)" # e.g. 9f3a1c2
fi
- On the default branch, images are pushed with both
:<TAG>and a moving:latest. The immutable:<sha>is what GitOps should pin;:latestis a convenience, never a deploy source. - Chart version comes from
Chart.yaml. On a tag build you may align it:helm package <dir> --version "${TAG#v}" --app-version "${TAG#v}". Keep chartversion(chart packaging) andappVersion(the image it deploys) conceptually distinct. - Never hardcode a tag in a Helm template or values file committed to the repo — the tag is a
pipeline output that flows into GitOps values. See the monorepo skill's
containers-and-helm.md.
The turbo prune build model — why context is the repo root
Each app's Dockerfile is multi-stage and runs turbo prune --scope=@<scope>/<app> --docker
inside the build to isolate that app plus its workspace dependencies, then installs from the
pruned out/json/ before copying out/full/. That is what keeps image builds cacheable and
independent across the monorepo.
The consequence for the pipeline: the build context must be the repository root, and the
Dockerfile is selected with -f:
docker build -f "applications-backend/<svc>/Dockerfile" -t "${IMAGE}:${TAG}" .
# ^ context = repo root
Building with the app directory as context (docker build applications-backend/<svc>) fails —
turbo prune and COPY . . cannot see the workspace or the lockfile. This is the single most
common mistake; the template always builds from ..
Discovery loops
Images:
shopt -s nullglob
for df in applications-backend/*/Dockerfile applications-frontend/*/Dockerfile; do
app="$(basename "$(dirname "$df")")"
image="${HARBOR_ADDRESS}/${HARBOR_PROJECT}/${app}"
docker build -f "$df" -t "${image}:${TAG}" .
if [ "${PUBLISH}" = "true" ]; then
docker push "${image}:${TAG}"
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
docker tag "${image}:${TAG}" "${image}:latest" && docker push "${image}:latest"
fi
fi
done
Charts (OCI push to a charts subpath so charts and images don't collide in the project
namespace):
shopt -s nullglob
mkdir -p .cicd-charts
for chart in helm-charts/*/Chart.yaml; do
dir="$(dirname "$chart")"
helm lint "$dir"
helm package "$dir" -d .cicd-charts
done
if [ "${PUBLISH}" = "true" ]; then
for tgz in .cicd-charts/*.tgz; do
helm push "$tgz" "oci://${HARBOR_ADDRESS}/${HARBOR_PROJECT}/charts"
done
fi
nullglob matters: without it, a repo with no frontend apps (or no charts) would iterate over
the literal unexpanded glob and try to build a file named applications-frontend/*/Dockerfile.
Harbor login step
- name: Log in to Harbor
run: |
echo "${HARBOR_SECRET}" | docker login "${HARBOR_ADDRESS}" -u "${HARBOR_USERNAME}" --password-stdin
echo "${HARBOR_SECRET}" | helm registry login "${HARBOR_ADDRESS}" -u "${HARBOR_USERNAME}" --password-stdin
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 }}
On a pull_request the secret may be absent (org secrets are not exposed to fork PRs). The
template guards the login: it only logs in when PUBLISH=true, so PR builds still verify
without needing the credential. Base images pulled during the build must therefore be public,
or the build itself must run on a trusted (non-fork) branch.
runs-on and marketplace actions
runs-on:is a runner label, and each label maps to a container image. That image must providedocker,helm, andgit. The template shipsruns-on: [self-hosted]— change it to your registered label.- The only marketplace action used is
actions/checkout@v4. Gitea proxiesactions/*to github.com; if the runner has no egress, either mirror the action into your Gitea instance or replace checkout with a manual clone:- run: | git init -q . git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}" git checkout -q FETCH_HEAD env: GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
Verifying a change to the pipeline
- Push to a throwaway feature branch → confirm the run builds images and lints/packages
charts, and pushes nothing (
PUBLISH=false). - Read the log for each app and chart — a silently skipped app means the glob didn't match
(wrong directory, missing
Dockerfile/Chart.yaml). - Merge to
test→ confirm:<sha>and:latestimages and the chart appear in Harbor under the project. - Tag
v*→ confirm the tagged image and chart version.