Files
devsecops-ci-cd-gitea-3darch/references/pipeline-workflow.md
T
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.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; :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:

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 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:
    - 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.