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>
197 lines
7.0 KiB
Markdown
197 lines
7.0 KiB
Markdown
# 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
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
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; `:latest` is 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 chart `version`
|
|
(chart packaging) and `appVersion` (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`:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
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
|
|
|
|
```yaml
|
|
- 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
|
|
provide `docker`, `helm`, and `git`. The template ships `runs-on: [self-hosted]` — change it
|
|
to your registered label.
|
|
- The only marketplace action used is `actions/checkout@v4`. Gitea proxies `actions/*` to
|
|
github.com; if the runner has no egress, either mirror the action into your Gitea instance or
|
|
replace checkout with a manual clone:
|
|
```yaml
|
|
- 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
|
|
|
|
1. Push to a throwaway feature branch → confirm the run **builds** images and **lints/packages**
|
|
charts, and pushes **nothing** (`PUBLISH=false`).
|
|
2. Read the log for each app and chart — a silently skipped app means the glob didn't match
|
|
(wrong directory, missing `Dockerfile`/`Chart.yaml`).
|
|
3. Merge to `test` → confirm `:<sha>` and `:latest` images and the chart appear in Harbor under
|
|
the project.
|
|
4. Tag `v*` → confirm the tagged image and chart version.
|