Diagnosed definitively rather than by log-guessing this time: $ curl -i https://registry.gitlab.vnc.biz/v2/ www-authenticate: Bearer realm="http://gitlab.vnc.biz/jwt/auth", service="dependency_proxy" x-runtime: 0.020470 x-gitlab-meta: {"correlation_id":...} x-runtime/x-gitlab-meta are Rails headers and the service is "dependency_proxy" - nginx routes that hostname to the GitLab Rails app, which treats /v2/ as the Docker Hub pull-through cache, not as this project's container registry. The registry service was never wired behind the vhost, which is why an unscoped docker login succeeded while kaniko's scoped :push request got 403 (the dependency proxy has no push concept). Fixing that is server-side nginx/omnibus work. Keeping kaniko (it solved the real dind-needs-privileged problem) and pointing it at GHCR, plus an upfront credential check so a missing variable fails in seconds instead of after a full Next.js build.
232 lines
11 KiB
YAML
232 lines
11 KiB
YAML
# GitLab-CI dev→prod pipeline for VNCmail+ — GitOps via ArgoCD.
|
|
#
|
|
# Revised after direct inspection of the real infrastructure found ArgoCD
|
|
# already installed (idle, zero Applications) on the dev-k8s-1/2/3 cluster.
|
|
# That's more idiomatic than a runner-executes-kubectl design, and it means
|
|
# this pipeline needs ZERO cluster credentials — CI only ever talks to the
|
|
# container registry and to this git repo. ArgoCD (which already has
|
|
# whatever cluster access it needs, set up once when its Applications were
|
|
# registered — see deploy/argocd/) is what actually applies anything.
|
|
#
|
|
# Design:
|
|
# - One image name, environment lives only in the tag. No more -dev/-beta
|
|
# name confusion from the old GitHub Actions workflow.
|
|
# - MR into `dev`: verify only (typecheck/lint/unit test/build check). No
|
|
# push, no deploy — this is the multi-developer merge gate.
|
|
# - Push to `dev`: build+push an immutable `sha-<sha>` tag, then commit a
|
|
# one-line tag-bump into overlays/dev/image-tag/kustomization.yaml
|
|
# (`[skip ci]`, so this doesn't retrigger itself). ArgoCD's `vncmail-dev`
|
|
# Application has automated sync — it notices the git change and applies
|
|
# it. No approval needed, dev always deploys, and this job never touches
|
|
# the cluster directly.
|
|
# - Push to `main`: NEVER rebuilds. `main` only ever advances via
|
|
# `git merge --ff-only dev`, so main's HEAD commit already has a built
|
|
# image (the same sha- tag dev already deployed). This job just bumps
|
|
# overlays/prod/image-tag/kustomization.yaml to point at that same tag.
|
|
# The actual promotion gate is a HUMAN clicking Sync on the `vncmail-prod` ArgoCD
|
|
# Application (deliberately NOT automated sync) — not a GitLab manual
|
|
# job, since ArgoCD already provides that exact gate more directly.
|
|
# Until prod Stalwart/hostname/secrets are real (see VNCMAIL-SETUP.md),
|
|
# nobody should click that Sync button — but nothing here does it for
|
|
# you either way.
|
|
#
|
|
# Deliberately single-platform (linux/amd64) — this pipeline serves two
|
|
# known amd64 microk8s clusters, not public multi-arch distribution (that's
|
|
# what the GHCR release workflows are for, untouched by this file).
|
|
#
|
|
# Registry history (so nobody re-litigates this from scratch). GitLab's own
|
|
# Container Registry was tried twice and does not work on this server:
|
|
#
|
|
# Round 1: registry_external_url was unset, so $CI_REGISTRY was empty and
|
|
# docker login silently fell through to Docker Hub.
|
|
# Round 2: after the server-side config, the project's sidebar DID show
|
|
# "Container Registry" and docker login DID succeed - but the push
|
|
# failed 403. Diagnosed 2026-08-05 by curling the vhost directly:
|
|
#
|
|
# $ curl -i https://registry.gitlab.vnc.biz/v2/
|
|
# www-authenticate: Bearer realm="http://gitlab.vnc.biz/jwt/auth",
|
|
# service="dependency_proxy"
|
|
# x-runtime: 0.020470
|
|
# x-gitlab-meta: {"correlation_id":...}
|
|
#
|
|
# x-runtime/x-gitlab-meta are RAILS headers, and the service is
|
|
# "dependency_proxy" - so nginx routes that hostname to the GitLab
|
|
# Rails app, which reads /v2/ as the dependency proxy (a Docker Hub
|
|
# pull-through cache), NOT to the registry container. The registry
|
|
# service was never actually wired behind that vhost. That is why
|
|
# login worked (Rails issues an unscoped dependency-proxy token)
|
|
# while a scoped :push request 403'd - the dependency proxy has no
|
|
# push concept at all.
|
|
#
|
|
# Fixing that is an nginx/omnibus change on the GitLab server (registry
|
|
# service must actually listen behind registry.gitlab.vnc.biz), not something
|
|
# any .gitlab-ci.yml can reach. Until someone does that, GHCR it is - the
|
|
# same image the sandbox already pulls, and confirmed public so the cluster
|
|
# needs no imagePullSecrets (see deploy/k8s/base/deployment.yaml).
|
|
#
|
|
# Prerequisite this file assumes (documented in VNCMAIL-SETUP.md, not
|
|
# something this file can set up itself):
|
|
# - A GitHub PAT with `write:packages` for ghcr.io/brvncde-dotcom as
|
|
# $GITLAB_CI_GHCR_TOKEN, plus the matching GitHub username as
|
|
# $GITLAB_CI_GHCR_USER — both masked+protected CI/CD variables. A GitHub
|
|
# credential can only come from GitHub; nothing GitLab-native substitutes.
|
|
# - A GitLab Runner (any kind — no cluster access needed at all now).
|
|
# - Either "allow this job token to push to this project" enabled
|
|
# (Settings → CI/CD → Job token permissions), OR a project access token
|
|
# with `write_repository` scope in $GITLAB_PUSH_TOKEN. The job below
|
|
# tries CI_JOB_TOKEN first (see the script).
|
|
#
|
|
# deploy/k8s/ca/ (the EJBCA internal CA) is never referenced anywhere below,
|
|
# and neither ArgoCD Application in deploy/argocd/ points at it — that stays
|
|
# a fully manual, human-only runbook (see deploy/k8s/ca/README.md).
|
|
|
|
stages:
|
|
- verify
|
|
- build
|
|
- bump-dev
|
|
- bump-prod
|
|
|
|
variables:
|
|
# GHCR, not GitLab's own registry - see the "Registry history" note in the
|
|
# header for the curl output proving why. Same image the sandbox already
|
|
# pulls today.
|
|
IMAGE: ghcr.io/brvncde-dotcom/vncmail-plus-dev
|
|
GIT_STRATEGY: clone
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# verify — required check on every MR into dev. No registry, no cluster.
|
|
# ---------------------------------------------------------------------------
|
|
verify:
|
|
stage: verify
|
|
image: node:24-alpine
|
|
rules:
|
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
script:
|
|
- npm ci
|
|
- npm run typecheck
|
|
- npm run lint
|
|
- npm run test:translations
|
|
- npm run build
|
|
# test:integration is deliberately NOT here — it spins up a real Stalwart
|
|
# fixture via docker-compose, which needs an actual Docker daemon this
|
|
# runner's Kubernetes executor doesn't provide without privileged mode
|
|
# (see the build job below). Candidate for a separate scheduled job on a
|
|
# differently-configured runner, not a blocker on every MR.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build — push to dev only. Builds once; main never rebuilds (see header).
|
|
# ---------------------------------------------------------------------------
|
|
build:
|
|
stage: build
|
|
# Kaniko builds OCI images without a Docker daemon, so it needs neither a
|
|
# dind service nor a privileged pod — GitLab's own recommended approach
|
|
# for the Kubernetes executor specifically. The docker:27-cli + dind
|
|
# combination that was here before this got as far as a successful
|
|
# $CI_REGISTRY login, then failed every way it was pointed
|
|
# (unix:///var/run/docker.sock, tcp://docker:2375, tcp://localhost:2375):
|
|
# the dind container itself was never actually listening, which on this
|
|
# executor means it needs `privileged: true` in the runner's own
|
|
# config.toml — a cluster/GitLab-admin setting outside this file's
|
|
# control. Kaniko sidesteps that requirement entirely rather than chasing
|
|
# runner permissions further, and is also the safer default on a shared
|
|
# cluster (no privileged containers at all).
|
|
image:
|
|
name: gcr.io/kaniko-project/executor:v1.23.2-debug
|
|
entrypoint: [""]
|
|
rules:
|
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
|
script:
|
|
# Fail loudly and immediately if the GHCR credentials aren't configured,
|
|
# rather than letting kaniko get all the way through a full Next.js build
|
|
# and only then 403 on push (which is exactly how the GitLab-registry
|
|
# attempt burned several pipeline runs).
|
|
- |
|
|
if [ -z "$GITLAB_CI_GHCR_TOKEN" ] || [ -z "$GITLAB_CI_GHCR_USER" ]; then
|
|
echo "ERROR: \$GITLAB_CI_GHCR_TOKEN and/or \$GITLAB_CI_GHCR_USER are not set."
|
|
echo "Add both under Settings -> CI/CD -> Variables (masked + protected)."
|
|
echo "The token is a GitHub PAT with the write:packages scope."
|
|
exit 1
|
|
fi
|
|
- mkdir -p /kaniko/.docker
|
|
- |
|
|
echo "{\"auths\":{\"ghcr.io\":{\"auth\":\"$(printf '%s:%s' "$GITLAB_CI_GHCR_USER" "$GITLAB_CI_GHCR_TOKEN" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
|
|
- >
|
|
/kaniko/executor
|
|
--context "$CI_PROJECT_DIR"
|
|
--dockerfile "$CI_PROJECT_DIR/Dockerfile"
|
|
--build-arg GIT_COMMIT=$CI_COMMIT_SHA
|
|
--destination "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
|
--destination "$IMAGE:dev-latest"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bump-dev — no cluster access. Commits the just-built tag into the overlay
|
|
# ArgoCD watches; ArgoCD's automated sync does the actual apply.
|
|
# ---------------------------------------------------------------------------
|
|
bump-dev:
|
|
stage: bump-dev
|
|
image: alpine/git:2.47.0
|
|
rules:
|
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
|
script:
|
|
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
|
- |
|
|
cat > deploy/k8s/overlays/dev/image-tag/kustomization.yaml <<EOF
|
|
# Owned by CI (bump-dev job in .gitlab-ci.yml) - regenerated every
|
|
# push to dev. Do not hand-edit; edits here get overwritten.
|
|
apiVersion: kustomize.config.k8s.io/v1alpha1
|
|
kind: Component
|
|
images:
|
|
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
|
|
newName: $IMAGE
|
|
newTag: $TAG
|
|
EOF
|
|
- git config user.name "vncmail-ci"
|
|
- git config user.email "ci@vnc.biz"
|
|
- git add deploy/k8s/overlays/dev/image-tag/kustomization.yaml
|
|
- |
|
|
if git diff --cached --quiet; then
|
|
echo "No change (tag already pinned) - nothing to commit"
|
|
else
|
|
git commit -m "chore(deploy): pin dev to $TAG [skip ci]"
|
|
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:dev
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bump-prod — no cluster access, no rebuild. Points overlays/prod at the
|
|
# exact tag already running on dev. Does NOT deploy anything: vncmail-prod's
|
|
# ArgoCD Application has manual sync, so this only prepares what a human
|
|
# would be syncing, it doesn't sync it.
|
|
# ---------------------------------------------------------------------------
|
|
bump-prod:
|
|
stage: bump-prod
|
|
image: alpine/git:2.47.0
|
|
rules:
|
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
script:
|
|
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
|
- echo "main advanced to $CI_COMMIT_SHA (must be a dev commit, ff-only) - that image already exists as $IMAGE:$TAG"
|
|
- |
|
|
cat > deploy/k8s/overlays/prod/image-tag/kustomization.yaml <<EOF
|
|
# Owned by CI (bump-prod job in .gitlab-ci.yml) - regenerated every
|
|
# push to main. Do not hand-edit; edits here get overwritten. Bumping
|
|
# this is NOT the same as deploying it - vncmail-prod's ArgoCD
|
|
# Application has manual sync, see the note in the parent
|
|
# kustomization.yaml.
|
|
apiVersion: kustomize.config.k8s.io/v1alpha1
|
|
kind: Component
|
|
images:
|
|
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
|
|
newName: $IMAGE
|
|
newTag: $TAG
|
|
EOF
|
|
- git config user.name "vncmail-ci"
|
|
- git config user.email "ci@vnc.biz"
|
|
- git add deploy/k8s/overlays/prod/image-tag/kustomization.yaml
|
|
- |
|
|
if git diff --cached --quiet; then
|
|
echo "No change (tag already pinned) - nothing to commit"
|
|
else
|
|
git commit -m "chore(deploy): point prod overlay at $TAG (not synced - manual gate in ArgoCD) [skip ci]"
|
|
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
|
|
fi
|