Skip to content

chore(deps): run e2e on Kubernetes 1.37, move go-git v6 to beta.1, and unblock the 0.52.0 release lint #1784

chore(deps): run e2e on Kubernetes 1.37, move go-git v6 to beta.1, and unblock the 0.52.0 release lint

chore(deps): run e2e on Kubernetes 1.37, move go-git v6 to beta.1, and unblock the 0.52.0 release lint #1784

Workflow file for this run

# Validation pipeline: everything needed to trust a commit, nothing that
# publishes. Runs directly for pull requests (fork-safe: no secrets, no
# registry writes) and is called as a reusable workflow by release.yml for
# pushes to main. See docs/ci-overview.md for the full CI/CD design.
#
# Trust model in one rule: untrusted (PR) code is built and tested, but never
# meets a write token. On PRs images move between jobs as artifacts; on
# trusted runs they are pushed to ghcr.io so later jobs (and the release
# workflow) can pull them. GitHub enforces the fork side of this: fork PRs
# always get a read-only GITHUB_TOKEN, whatever permissions are declared here.
name: CI
on:
# Every pull request, whatever its base — deliberately NOT filtered to `branches: [main]`.
# A stacked PR (PR N based on PR N-1's branch) never matches a main-only filter, so it
# merged with no lint, no unit tests, and no e2e: PR #255 ran only the external review
# bots. Retargeting does not rescue it either — when the base branch merges, GitHub
# retargets the open PR onto main and fires `pull_request` with action `edited`, which
# is not in the default trigger set (opened/synchronize/reopened), so nothing re-runs.
# Validating every base costs a little runner time and is what makes a stack reviewable
# one PR at a time.
pull_request:
workflow_call:
outputs:
ci-image:
description: CI base container image (pushed to ghcr.io on trusted runs)
value: ${{ jobs.ci-container.outputs.image }}
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: configbutler/gitops-reverser
IMAGE_TAG: ci-${{ github.sha }}
CI_WORKDIR: /workspaces/${{ github.event.repository.name }}
permissions:
contents: read
# PR runs dedupe per PR (a new push cancels the previous run). When called
# from release.yml there is no PR number; github.run_id keeps the group unique
# so the called run never interferes with the caller's own concurrency group.
concurrency:
group: ci-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
ci-container:
name: CI base container
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image: ${{ steps.image.outputs.name }}
permissions:
contents: read
# Only trusted (non-PR) runs push. Fork PRs get a read-only token from
# GitHub regardless of this declaration; same-repo PR authors already
# have repo write access, so nothing is escalated for them either.
packages: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set image name
id: image
run: |
IMAGE="${REGISTRY}/configbutler/gitops-reverser-ci:${{ github.sha }}"
echo "Building CI base container: ${IMAGE}"
echo "name=${IMAGE}" >> "$GITHUB_OUTPUT"
- name: Build CI base container
# PRs build and load locally only (their token cannot publish, and
# untrusted code must not publish anyway); trusted runs also push.
# :latest is only moved from main so a workflow_dispatch on a topic
# branch can't repoint it.
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: .devcontainer/Dockerfile
target: ci
push: ${{ github.event_name != 'pull_request' }}
load: true # So the validate step below can run without pulling
tags: |
${{ steps.image.outputs.name }}
${{ github.ref == 'refs/heads/main' && format('{0}/configbutler/gitops-reverser-ci:latest', env.REGISTRY) || '' }}
cache-from: type=gha,scope=ci-container
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=ci-container' || '' }}
- name: Validate CI container tools
run: |
docker run --rm ${{ steps.image.outputs.name }} bash -lc "
set -e
echo '=== Validating CI Container Tools ==='
go version
kubectl version --client
kustomize version
helm version
flux-operator version --client
golangci-lint version
actionlint --version
hadolint --version
markdownlint-cli2 --version
vale --version
controller-gen --version
k3d version
docker --version
cosign version
echo '✅ All CI container tools verified'
"
- name: Save CI container image (PR)
# PR builds aren't pushed; hand the image to later jobs as an artifact.
if: github.event_name == 'pull_request'
run: docker save "${{ steps.image.outputs.name }}" -o ci-container-image.tar
- name: Upload CI container image artifact (PR)
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ci-container-image
path: ci-container-image.tar
if-no-files-found: error
retention-days: 1
build:
name: Project image (instrumented, artifact-only)
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image: ${{ steps.image.outputs.name }}
permissions:
# No push on any run: this image is never published, so no write token.
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set full image name and build metadata
id: image
run: |
FULL_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}"
echo "name=${FULL_IMAGE}" >> "$GITHUB_OUTPUT"
echo "git_commit=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
echo "Building image: ${FULL_IMAGE}"
- name: Build Docker image
# Coverage-instrumented (GOCOVER=1), amd64-only. Never pushed on any
# run: delivered to later jobs (e2e) as a docker-save artifact and
# imported into k3d via IMAGE_DELIVERY_MODE=load — the fork-safe path
# every PR already exercises, now the only path. The registry-served
# bytes are the release-grade build-release-* images, so no
# instrumented digest can ever be mistaken for promotable. Keeps the
# ci-<sha> tag name only as a local Docker/k3d reference.
uses: ./.github/actions/docker-build
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
login: false
platforms: linux/amd64
tags: ${{ steps.image.outputs.name }}
push: false
load: true
# Own cache scope so the instrumented build and the release-grade
# build-linux/amd64 scope no longer invalidate each other.
cache-scope: build-instrumented-linux/amd64
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=build-instrumented-linux/amd64' || '' }}
build-args: |
VERSION=${{ env.IMAGE_TAG }}
GIT_COMMIT=${{ steps.image.outputs.git_commit }}
GIT_DIRTY=0
BUILD_DATE=${{ steps.image.outputs.build_date }}
GOCOVER=1
- name: Save project image
# Delivered as an artifact on every run (PR and main): e2e loads it and
# imports it into k3d.
run: docker save "${{ steps.image.outputs.name }}" -o project-image.tar
- name: Upload project image artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: project-image
path: project-image.tar
if-no-files-found: error
# ~7 days (was 1): a failed main e2e can still be reproduced from the
# exact instrumented image that ran, now that it is never pushed.
retention-days: 7
# Release-grade images: clean (no coverage), semver-stamped, pushed by digest
# on every push to main so the release tail can retag instead of rebuild.
# Two named jobs, not a matrix: publish-manifest needs each per-arch digest
# individually, and matrix jobs share one output set (last writer wins).
#
# Gated on `push` + `refs/heads/main` (not `!= 'pull_request'`, which would
# also fire release builds from a workflow_dispatch on any branch). Note:
# gating on `event_name == 'workflow_call'` would never match — inside a
# called workflow the github context belongs to the *caller*, so a release.yml
# push shows up here as event_name == 'push'.
build-release-amd64:
name: Release image linux/amd64
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Compute release build metadata
id: meta
run: |
# On the release-bump commit this is the new semver — release-please
# bumped .release-please-manifest.json in the PR before merge. On
# ordinary main pushes it is the *previous* released version; that is
# fine because only the bump commit's digest is ever promoted to a
# semver tag, and GIT_COMMIT disambiguates never-promoted candidates.
VERSION="$(jq -r '."."' .release-please-manifest.json)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "git_commit=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Extract OCI labels
id: docker_meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push release image by digest
id: build
uses: ./.github/actions/docker-build
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
platforms: linux/amd64
labels: ${{ steps.docker_meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true
# Reuses the release cache scope: same build args at release time, so
# every main push keeps this cache warm.
cache-scope: build-linux/amd64
cache-to: type=gha,mode=max,scope=build-linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GIT_COMMIT=${{ steps.meta.outputs.git_commit }}
GIT_DIRTY=0
BUILD_DATE=${{ steps.meta.outputs.build_date }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-amd64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-release-arm64:
name: Release image linux/arm64
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Compute release build metadata
id: meta
run: |
# See build-release-amd64: previous version on non-bump commits is
# expected and harmless.
VERSION="$(jq -r '."."' .release-please-manifest.json)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "git_commit=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Extract OCI labels
id: docker_meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push release image by digest
id: build
uses: ./.github/actions/docker-build
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
platforms: linux/arm64
labels: ${{ steps.docker_meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true
cache-scope: build-linux/arm64
cache-to: type=gha,mode=max,scope=build-linux/arm64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GIT_COMMIT=${{ steps.meta.outputs.git_commit }}
GIT_DIRTY=0
BUILD_DATE=${{ steps.meta.outputs.build_date }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-arm64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
validate-devcontainer:
name: Validate Dev Container
runs-on: ubuntu-latest
timeout-minutes: 30
needs: ci-container
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build dev container (validation only)
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: .devcontainer/Dockerfile
target: dev
push: false
load: true
tags: gitops-reverser-devcontainer:test
cache-from: type=gha,scope=devcontainer
cache-to: ${{ github.event_name == 'pull_request' && '' || 'type=gha,scope=devcontainer,mode=max' }}
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
- name: Validate dev container tools
run: |
docker run --rm gitops-reverser-devcontainer:test bash -lc "
set -e
export PATH=/usr/local/go/bin:/go/bin:\$PATH
echo '=== Validating Dev Container Tools ==='
go version
kubectl version --client
kustomize version
helm version
flux-operator version --client
golangci-lint version
actionlint --version
markdownlint-cli2 --version
vale --version
k3d version
task --version
bash -ic 'complete -p task >/dev/null'
docker --version
echo '✅ All dev container tools verified'
"
lint-helm:
name: Build Helm chart and installer bundle
runs-on: ubuntu-latest
timeout-minutes: 20
needs: ci-container
permissions:
contents: read
packages: read
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Lint and package Helm chart
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task helm-sync
helm lint charts/gitops-reverser
helm template gitops-reverser charts/gitops-reverser \
--namespace gitops-reverser-system \
--set image.repository=test/image \
--set image.tag=test
task dist-install
helm package charts/gitops-reverser --destination .
mv gitops-reverser-*.tgz gitops-reverser.tgz
'
- name: Upload release bundle artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-bundle
path: |
dist/crds.yaml
dist/install.yaml
gitops-reverser.tgz
if-no-files-found: error
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 20
needs: ci-container
permissions:
contents: read
packages: read
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Run lint tasks
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task lint
cache_log="$(mktemp)"
task --dry --verbose lint 2>&1 | tee "${cache_log}"
for task_name in \
generate \
manifests \
helm-sync \
lint-golang \
lint-dockerfiles \
lint-actions \
lint-helm
do
if ! grep -Fq "Task \"${task_name}\" is up to date" "${cache_log}"; then
echo "Expected ${task_name} to be cached after task lint" >&2
exit 1
fi
done
# The five docs tasks are exempt on purpose, and all for one reason: none
# of them carries `sources:` (see Taskfile-build.yml). Their real input is
# "every tracked file" — every .md for lint-markdown and lint-prose, plus
# .go, .yml and .sh for lint-doc-links and lint-metric-names — which Task
# cannot express: an unrooted `**` glob walks external-sources/ and has
# OOMed the host, and a hand-maintained file list silently drifts out of
# sync with what the checkers scan. A stale "up to date" means the check
# never runs, and lint-metric-names exists precisely because a check that
# does not run reads as a confident zero. All four tools together cost a few
# seconds over the whole tree, so they always run. lint-docs itself is the
# aggregate and has no cmds of its own.
#
# Adding a task to `task lint` without `sources:` and without adding it
# here fails this step. That is deliberate: it forces the choice to be made
# rather than silently skipped.
#
# awk, not `grep -qv`: the CI image ships ugrep as grep, whose -q -v returns
# 1 even when -c reports a selected line. That would make this assertion
# silently pass whatever it was handed.
exempt="lint-docs|lint-doc-links|lint-markdown|lint-prose|lint-metric-names"
uncached="$(awk -v ex="^task: \\\\[(${exempt})\\\\]" "/^task: \\[/ && \$0 !~ ex" "${cache_log}")"
if [ -n "${uncached}" ]; then
echo "Expected second task lint dry-run to be fully cached apart from the docs tasks, but these would run:" >&2
printf "%s\n" "${uncached}" >&2
exit 1
fi
'
- name: GitOps layout baseline is current
# test/fixtures/gitops-layouts/support-today.md is generated from a live
# manifest-analyzer scan of the corpus. Regenerating it here and failing on a
# diff is what makes a change to the acceptance boundary show up in review as
# "exactly which fixtures moved, and in which direction". Without this check the
# baseline silently goes stale, and the corpus stops being evidence of anything.
#
# A failure is not necessarily a bug: if the boundary moved on purpose, run
# `task gitops-layouts-baseline` and commit the regenerated file. Review the diff.
#
# Kept out of `task lint` deliberately: the lint step above asserts that a second
# dry-run is fully cached, and this task's fingerprint would have to be threaded
# through that assertion for no benefit.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task gitops-layouts-baseline --force
if ! git diff --quiet -- test/fixtures/gitops-layouts/support-today.md; then
# No backticks in the message below. It is a double-quoted string inside a
# bash -lc block, where backticks are command substitution: the echo would
# RUN the task a second time and splice its stdout into the annotation.
echo "::error file=test/fixtures/gitops-layouts/support-today.md::The GitOps layout baseline is stale: the analyzer now reports something different for the corpus. If the acceptance boundary moved on purpose, run task gitops-layouts-baseline and commit the result."
git --no-pager diff -- test/fixtures/gitops-layouts/support-today.md
exit 1
fi
echo "baseline is current"
'
test:
name: Unit tests
runs-on: ubuntu-latest
timeout-minutes: 30
needs: ci-container
permissions:
contents: read
packages: read
id-token: write
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Run tests
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task test
'
- name: Make test artifacts readable by the runner
if: always()
run: sudo chmod -R a+rX cover.out .coverage-baseline config/crd/bases config/rbac config/webhook 2>/dev/null || true
- name: Upload unit coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: cover.out
flags: unit
use_oidc: true
fail_ci_if_error: false
image-scan:
name: Scan project image
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [build, ci-container]
# PRs only: scans the instrumented image the PR built (delivered as an
# artifact). On main the shipped bytes are the release-grade digests, which
# image-scan-release scans instead — so this scan reports to the job log
# and never needs a write token.
if: github.event_name == 'pull_request'
permissions:
contents: read
packages: read
env:
PROJECT_IMAGE: ${{ needs.build.outputs.image }}
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
# Inside the container this is a workspace path, so the runner-side cache step
# below can persist it between runs. Without a cache Trivy re-downloads its whole
# vulnerability DB on every job, which is slow and rate-limited at the source.
TRIVY_CACHE_DIR: ${{ github.workspace }}/.stamps/trivy-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download project image
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: project-image
path: .
- name: Download CI container image
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ci-container-image
path: .
- name: Load CI container image
# rm after load: the tarball is multi-GB and otherwise occupies the runner
# for the rest of the job.
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Restore Trivy vulnerability DB
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .stamps/trivy-cache
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Scan project image
# The project image is scanned straight from its artifact tarball, so this
# needs neither a `docker load` of it nor a registry pull. What the task does
# — report everything, then gate on CRITICAL-with-a-fix — is documented where
# it is defined; keeping it there is what lets a maintainer reproduce this
# exact gate with `task scan-image SCAN_ARCHIVE=project-image.tar`.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
-e TRIVY_CACHE_DIR="${{ env.CI_WORKDIR }}/.stamps/trivy-cache" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task scan-image SCAN_ARCHIVE=project-image.tar
'
- name: Make the Trivy cache readable by the runner
# The container writes it as root; actions/cache runs as the runner user.
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .stamps/trivy-cache || true
image-scan-release:
name: Scan release image (${{ matrix.arch }})
runs-on: ubuntu-latest
timeout-minutes: 20
# Rides its build jobs' gate: when build-release-* are skipped (PRs,
# dispatch) this skips transitively — no skip-tolerant `if:` needed. The
# release ships amd64 *and* arm64, so both digests are scanned; Trivy pulls
# each by digest and analyzes the filesystem cross-arch.
needs: [ci-container, build-release-amd64, build-release-arm64]
permissions:
contents: read
packages: read
security-events: write
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
TRIVY_CACHE_DIR: ${{ github.workspace }}/.stamps/trivy-cache
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
digest: ${{ needs.build-release-amd64.outputs.digest }}
- arch: arm64
digest: ${{ needs.build-release-arm64.outputs.digest }}
steps:
- name: Checkout code
# This job scans a REMOTE image by digest and needs nothing from the repository
# except the Taskfile and .trivyignore.yaml the scan below reads. Trivy treats a
# missing --ignorefile as a fatal error, so a lost checkout fails loudly here
# rather than quietly scanning without the suppressions.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to registry
# Each build-release-* job pushes by digest, and buildx wraps the
# single-platform image in an OCI *index* (it attaches a provenance
# attestation manifest). Trivy resolves the digest remotely but, for an
# index ref, selects the child matching the *runner's* platform — so on
# this amd64 runner the arm64 leg fails with "no child with platform
# linux/amd64 in index". The Trivy steps below pin TRIVY_PLATFORM to the
# matrix arch to select the right child. The login just covers
# authenticated pulls; the package is public anyway.
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Restore Trivy vulnerability DB
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .stamps/trivy-cache
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Scan release image
# The same `task scan-image` the PR gate runs, so the release and the PR are
# held to one definition of what blocks. It writes SARIF for code scanning and
# then gates; the upload below runs even when the gate fails, so a blocking
# finding is still reported rather than only logged.
#
# Trivy pulls the image with its OWN registry client rather than through the
# docker daemon, so the runner's `docker login` above does not reach it: the
# credentials live in the runner's home, and this container sees only the
# workspace. Mounting that config read-only and pointing DOCKER_CONFIG at it is
# what keeps this working if the package ever stops being public. Read-only, and
# outside the workspace, so the credential is never somewhere an artifact upload
# could pick it up. DOCKER_CONFIG rather than a mount onto ~/.docker because it
# does not care which user the image runs as.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-v "${HOME}/.docker:/tmp/docker-config:ro" \
-w "${{ env.CI_WORKDIR }}" \
-e DOCKER_CONFIG=/tmp/docker-config \
-e TRIVY_CACHE_DIR="${{ env.CI_WORKDIR }}/.stamps/trivy-cache" \
-e TRIVY_PLATFORM="linux/${{ matrix.arch }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task scan-image \
SCAN_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ matrix.digest }} \
SCAN_SARIF=trivy-image.sarif
'
- name: Make scan output readable by the runner
# The container writes both as root; the upload action and actions/cache run
# as the runner user.
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .stamps/trivy-cache trivy-image.sarif || true
- name: Upload scan results to GitHub code scanning
# always(): the SARIF is written by the report pass BEFORE the gate runs, so a
# gate failure must not swallow the report that explains it.
if: always()
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
sarif_file: trivy-image.sarif
# Distinct category so the amd64 and arm64 legs don't overwrite each
# other's code-scanning results.
category: trivy-release-${{ matrix.arch }}
e2e:
name: E2E (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [ci-container, build, lint-helm]
permissions:
contents: read
packages: read
id-token: write
strategy:
# Don't let one lane's flake cancel the other mid-run — a cancelled lane
# loses its signal *and* fails its report upload with "No files found".
fail-fast: false
matrix:
include:
# Functional suite, sharded across two runners (was the single `full`
# lane). The two label filters are exact complements within the old
# `!image-refresh` filter — `manager` ∪ `!manager && !image-refresh`
# equals what `full` ran — so no spec is added or dropped, the work is
# just split across two clusters. The membership is a provisional
# first cut; rebalance from the e2e-ginkgo-reports-* per-spec timings
# (docs/finished/e2e-ci-runner-sharding-plan.md, "balance from real data").
# Each shard runs config-dir installs (GOCOVERDIR overlay) and collects
# coverage; Codecov unions same-flag `e2e` uploads, so merged coverage
# is the union of both shards — the non-regression ratchet still holds.
- name: full-manager
script: "task test-e2e"
e2e_label_filter: "manager"
e2e_report_name: "full-manager"
needs_artifact: false
coverage: "1"
# Bumped 1 -> 4: the full suite was serialized to avoid the
# cross-target rule-change snapshot coupling, now fixed (per-target
# effective watch plan — docs/spec/gittarget-isolation-on-rule-change.md).
e2e_ginkgo_procs: "4"
- name: full-core
script: "task test-e2e"
# `bi-directional` and `source-cluster` moved out to their own legs (they
# install Argo CD / kcp that `task prepare-e2e` does not perform), so this
# filter is now a strict subset of what it used to run.
e2e_label_filter: "!manager && !image-refresh && !bi-directional && !source-cluster"
e2e_report_name: "full-core"
needs_artifact: false
coverage: "1"
e2e_ginkgo_procs: "4"
# The bi-directional corner: the only leg that installs Argo CD, and the
# only place gitops-reverser shares a path with a foreign GitOps engine.
# Its own runner (hence its own cluster) is what keeps the Argo CD
# install off the other three legs.
# docs/spec/e2e-bi-directional-corner.md.
#
# `task test-e2e-bi-directional` pins its own label filter and Ginkgo
# procs (BI_DIRECTIONAL_GINKGO_PROCS), so e2e_label_filter and
# e2e_ginkgo_procs are deliberately absent here rather than set to values
# the task would ignore. It uses the default config-dir install, so it
# carries the GOCOVERDIR overlay and its coverage unions with the others'.
- name: bi-directional
script: "task test-e2e-bi-directional"
e2e_report_name: "bi-directional"
needs_artifact: false
coverage: "1"
# The source-cluster corner: the only leg that installs kcp, and the only
# place gitops-reverser mirrors REMOTE clusters (GitTarget.spec.kubeConfig).
# kcp workspaces are cheap logical clusters, so its own runner installs a
# small kcp control plane and provisions three workspaces as source clusters.
# docs/finished/config-plane-split.md.
#
# `task test-e2e-source-cluster` pins its own label filter and Ginkgo procs
# (SOURCE_CLUSTER_GINKGO_PROCS=1 — the specs share one kcp port-forward), so
# e2e_label_filter and e2e_ginkgo_procs are deliberately absent here. It uses
# the default config-dir install, so it carries the GOCOVERDIR overlay and its
# coverage unions with the others'.
- name: source-cluster
script: "task test-e2e-source-cluster"
e2e_report_name: "source-cluster"
needs_artifact: false
coverage: "1"
# Quickstart chain, sharded across two runners (was the single
# `quickstart` lane). quickstart-install runs the two install-mode
# validations on one cluster (cleanup-installs.sh resets the namespace
# between them, since install stamps don't invalidate on an image-ref
# change). image-refresh gets its own runner: it validates the local
# build → k3d load → rollout restart chain, so it must run against a
# locally built image — unset PROJECT_IMAGE and force load delivery.
# Fork-safe: neither leg pushes to a registry. Helm/manifest installs
# don't carry the GOCOVERDIR overlay, so these legs collect no coverage.
- name: quickstart-install
script: >-
export INSTALL_MODE=helm HELM_CHART_SOURCE=./gitops-reverser.tgz
&& task test-e2e-quickstart-helm
&& bash hack/cleanup-installs.sh k3d-gitops-reverser-test-e2e
&& export INSTALL_MODE=plain-manifests-file
&& task test-e2e-quickstart-manifest
needs_artifact: true
coverage: ""
e2e_ginkgo_procs: "2"
- name: image-refresh
script: >-
export INSTALL_MODE=helm HELM_CHART_SOURCE=./gitops-reverser.tgz
&& env -u PROJECT_IMAGE IMAGE_DELIVERY_MODE=load task test-image-refresh
needs_artifact: true
coverage: ""
e2e_ginkgo_procs: "2"
env:
PROJECT_IMAGE: ${{ needs.build.outputs.image }}
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
# The instrumented project image is never pushed (PR *or* main), so it is
# always delivered via `load`: the artifact is docker-loaded below and
# imported into k3d. The CI container is still pulled from GHCR on main
# (publish-helm needs a registry ref), so that delivery stays branched.
IMAGE_DELIVERY_MODE: load
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Free runner disk
# The e2e stack (CI container, project image, k3d node volumes, local
# rebuilds) needs tens of GB; runners have been observed dipping below
# the k3d node's kubelet eviction threshold mid-run (28624646437).
# Drop the biggest preinstalled toolchains we never use.
run: |
df -h / | tail -1
sudo rm -rf /usr/local/lib/android /opt/hostedtoolcache/CodeQL /usr/share/dotnet &
RM_PID=$!
docker system prune -f >/dev/null 2>&1 || true
wait "${RM_PID}"
df -h / | tail -1
- name: Download release bundle artifact
if: matrix.needs_artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-bundle
path: .
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download project image
# The project image is never pushed; pull the tarball built by the
# build job (on PR and main alike).
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: project-image
path: .
- name: Load project image into Docker
# Make PROJECT_IMAGE available in the runner's Docker daemon so the k3d
# image import (load delivery) finds it locally instead of pulling.
run: docker load -i project-image.tar && rm -f project-image.tar
- name: Start disk usage sampler
run: |
# Sample free space on / every 10s for the whole e2e run so we can see
# the peak (not just the post-run value). /var/lib/docker — k3d node
# volumes, pulled images, build cache — lives on this filesystem.
( while true; do
printf '%s avail=%s\n' "$(date -u +%H:%M:%S)" \
"$(df --output=avail -BM / | tail -1 | tr -d ' ')"
sleep 10
done ) > "df-samples-${{ matrix.name }}.log" 2>/dev/null &
echo "SAMPLER_PID=$!" >> "$GITHUB_ENV"
- name: Bring up cluster + Flux services (explicit, streaming logs)
run: |
# Install-mode-/image-independent slice of prepare-e2e. Runs the
# slow, hang-prone cluster + Flux + services bring-up here so its
# logs stream live, instead of being buffered inside Ginkgo's
# SynchronizedBeforeSuite (where a stall just looks like a silent
# 15m hang ending in a goroutine dump). The in-suite prepare-e2e
# no-ops this part because the stamps already exist.
docker run --rm \
--network host \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w "${{ env.CI_WORKDIR }}" \
-e IMAGE_DELIVERY_MODE=${{ env.IMAGE_DELIVERY_MODE }} \
-e HOST_PROJECT_PATH=${{ github.workspace }} \
-e KUBECONFIG=${{ env.CI_WORKDIR }}/.stamps/cluster/k3d-gitops-reverser-test-e2e/.kube/config \
${{ env.CI_CONTAINER }} \
bash -c "
git config --global --add safe.directory ${{ env.CI_WORKDIR }}
# The cluster is created here but reused by the test container
# below. start-cluster.sh writes the k3d context into KUBECONFIG;
# point it at a file on the mounted workspace so the next
# container inherits the context (otherwise it is lost with this
# ephemeral container and kubectl reports 'context does not exist').
# Living under .stamps/cluster/<ctx>/.kube keeps it tied to the
# cluster lifecycle: cleanup-installs.sh (per-namespace) leaves it
# alone, while clean-cluster (rm -rf .stamps/cluster/<ctx>) clears
# it together with the cluster it points at.
mkdir -p .stamps/cluster/k3d-gitops-reverser-test-e2e/.kube
task _services-ready
"
- name: Run E2E tests in CI container
# E2E_LABEL_FILTER / E2E_REPORT_NAME are passed through by name (not
# value) so the shell never sees the filter's spaces or `&&` — GitHub
# sets them as process env vars and `docker run -e VAR` forwards them.
# They're only consumed by `task test-e2e` (the functional shards); the
# quickstart legs leave them empty and ignore them.
env:
E2E_LABEL_FILTER: ${{ matrix.e2e_label_filter }}
E2E_REPORT_NAME: ${{ matrix.e2e_report_name }}
run: |
docker run --rm \
--network host \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w "${{ env.CI_WORKDIR }}" \
-e PROJECT_IMAGE=${{ env.PROJECT_IMAGE }} \
-e IMAGE_DELIVERY_MODE=${{ env.IMAGE_DELIVERY_MODE }} \
-e E2E_GINKGO_PROCS=${{ matrix.e2e_ginkgo_procs }} \
-e E2E_LABEL_FILTER \
-e E2E_REPORT_NAME \
-e HOST_PROJECT_PATH=${{ github.workspace }} \
-e KUBECONFIG=${{ env.CI_WORKDIR }}/.stamps/cluster/k3d-gitops-reverser-test-e2e/.kube/config \
${{ env.CI_CONTAINER }} \
bash -c "
git config --global --add safe.directory ${{ env.CI_WORKDIR }}
${{ matrix.script }}
"
- name: Collect E2E coverage
if: matrix.coverage == '1'
# Fail-safe: a coverage hiccup must never fail the e2e gate.
run: |
docker run --rm \
--network host \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w "${{ env.CI_WORKDIR }}" \
-e KUBECONFIG=${{ env.CI_WORKDIR }}/.stamps/cluster/k3d-gitops-reverser-test-e2e/.kube/config \
${{ env.CI_CONTAINER }} \
bash -c "
git config --global --add safe.directory ${{ env.CI_WORKDIR }}
task e2e-coverage-collect
" || echo "e2e coverage collection failed (non-fatal)"
- name: Upload E2E coverage to Codecov
if: matrix.coverage == '1'
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: e2e-cover.out
flags: e2e
use_oidc: true
fail_ci_if_error: false
- name: Report disk usage (peak + final)
if: always()
run: |
kill "${SAMPLER_PID}" 2>/dev/null || true
samples="df-samples-${{ matrix.name }}.log"
{
echo "### Disk usage (${{ matrix.name }})"
echo
echo "Lowest free space on \`/\` during the run:"
echo '```'
sort -t= -k2 -n "${samples}" 2>/dev/null | head -1 || echo "(no samples captured)"
echo '```'
echo "Final state:"
echo '```'
df -h /
echo
docker system df
echo '```'
echo "<details><summary>Full df sample timeline</summary>"
echo
echo '```'
cat "${samples}" 2>/dev/null || echo "(no samples captured)"
echo '```'
echo "</details>"
} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Make E2E artifacts readable by the runner
if: always()
# The E2E suite runs in a root container that mounts the workspace, so
# everything it writes under .stamps/ (e.g. e2e-repo-artifacts/) is
# root-owned with restrictive perms. The upload step below runs on the
# host as the non-root runner user and fails with EACCES when its glob
# tries to scandir those dirs. Relax perms so the runner can traverse
# and read them. a+rX adds read for files and traverse for dirs only.
run: sudo chmod -R a+rX .stamps || true
- name: Upload E2E raw Ginkgo JSON reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-ginkgo-reports-${{ matrix.name }}
path: .stamps/**/ginkgo-report-*.json
# Reports live under the dot-dir .stamps/, which upload-artifact treats
# as hidden and excludes by default — without this the upload silently
# finds no files. See the "No files were found" gotcha in upload-artifact v4+.
include-hidden-files: true
if-no-files-found: error
retention-days: 60