From ed06f952a1d21fafc5954c6b07d7b05d6365f470 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:27:49 -0700 Subject: [PATCH 1/6] ci: replace the legacy pipeline with the copier-rendered fleet workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the jacaudi/template alignment PR #42 started. That PR landed the non-workflow half — taskfile.yml, .taskfiles/, scripts/, the lint configs. This lands the workflows themselves, rendered from gh:jacaudi/template at a676cfe with the answers now recorded in .copier-answers.yml (deliberately withheld from #42 so the recorded template state never claimed more than was actually there). .github/actions/setup/ ships alongside the workflows because it has to: actionlint is verifiably blind to a `uses: ./path` that does not resolve, so a missing composite action passes lint and fails at run time. scripts/local-refs.sh is the gate that catches it, and it runs first in ci-lint. Deletes build-images.yaml, pull-request-workflow.yaml, manual-dev-release-workflow.yaml and release.yaml. Four things the template does not cover for this repo: 1. A `file:` INPUT ON ci-build.yml — a local deviation, and the one place `copier update` will conflict. Upstream cut `file:` on the reasoning that a repo whose Dockerfile is elsewhere moves its `context:` instead. That assumes the Dockerfile and the context can be the same directory. This repo publishes two images, images/manager and images/agent, and both `COPY go.mod cmd api internal` — so both need the repo root as context while neither sits at it. `context: .` finds no ./Dockerfile; `context: images/manager` finds one and dies at the COPY. There is no answer to build_context that builds either image. The block added here is ci-build-check.yml's resolution logic verbatim, so this is the template's own pattern, merely absent from the stage that publishes. Worth upstreaming so the deviation can be dropped. 2. A SECOND BUILD JOB for the agent. The template models one published image and its extra_build_contexts route to ci-build-check.yml, which declares no outputs and never pushes. The agent must actually be published — the manager takes it as a runtime argument — so it gets its own job, not a matrix leg: ci-build.yml declares outputs.image, and a matrixed job's outputs are last-leg-wins, which would hand smoke whichever digest finished last. build-agent is in the `ci` gate's needs list, or it could fail without failing `ci`. 3. A CODEGEN DRIFT GATE, carried over from PR #39. CRDs, RBAC and deepcopy are generated from the API types and committed, so they can fall behind with nothing to say so. Invoked as `make manifests generate` rather than a bare controller-gen call, so CONTROLLER_TOOLS_VERSION and the generator flags stay single-sourced in the Makefile. 4. A DRIFT GATE FOR release.yaml (hack/release-file-drift.sh). It is generated by kustomize from config/ and is this repo's documented install path. The pins are read back out of the committed file rather than passed in: the Makefile defaults are dev values, and the committed pins are release-scoped, so this checks structure against config/ and leaves versioning to release time. Both gates fail on drift and never fix it — a CI step that mutates the tree hides the problem it exists to surface. Also corrected, both verified rather than assumed: - ci-integration.yml's setup-envtest pin. The template's `v0.22.1` DOES NOT RESOLVE in any consumer: tools/setup-envtest is a separate module whose only released tags are v0.24.0 and v0.24.1, so a controller-runtime version number is not a valid version for it. Pinned to v0.24.1, and the renovate annotation now names that module instead of controller-runtime. - The envtest asset version, lowered from the template's 1.34.x to 1.30.0 to match this repo's k8s.io/* v0.30 client stack behind controller-runtime v0.18.5. Verified: the suite runs for 79s, the known envtest signature, rather than passing in seconds having run nothing. - release.yaml was tracked AND gitignored. Inert, because tracking wins, but the drift gate's own remediation is "regenerate and commit", and `git add` against an ignored path is a silent no-op. Two gaps recorded in ci.yaml rather than left to be discovered: - NO RELEASE WIRING. ci-release.yml requires APP_ID and APP_PRIVATE_KEY with no GITHUB_TOKEN fallback, and this repo has neither — the API reports total_count 0, with no environments and a personal-account owner. Wiring it anyway opens a release PR that never gets a run, never reports `ci`, and is unmergeable forever. The stage files and the manifest seeded at 2.11.0 are committed, so restoring the two jobs later is purely additive. - NO e2e STAGE. The deleted pull-request-workflow.yaml ran a kind-based third tier; the template models only unit and envtest. Not restored here because internal/it carries a known race, and a flaky suite wired into the single required check leaves main randomly unmergeable. Refs #37, #41. Completes the pipeline half of #36's registry decision. --- .copier-answers.yml | 14 + .github/actions/setup/action.yml | 170 +++++++++ .github/actions/setup/detect.sh | 107 ++++++ .github/release-please-config.json | 69 ++++ .github/release-please-manifest.json | 3 + .github/renovate.json | 7 + .github/workflows/build-images.yaml | 115 ------ .github/workflows/ci-build.yml | 225 +++++++++++ .github/workflows/ci-integration.yml | 84 +++++ .github/workflows/ci-lint.yml | 353 ++++++++++++++++++ .github/workflows/ci-release.yml | 154 ++++++++ .github/workflows/ci-retag.yml | 166 ++++++++ .github/workflows/ci-smoke.yml | 69 ++++ .github/workflows/ci.yaml | 181 +++++++++ .../manual-dev-release-workflow.yaml | 70 ---- .github/workflows/pull-request-workflow.yaml | 86 ----- .github/workflows/release-republish.yml | 158 ++++++++ .github/workflows/release.yaml | 16 - .gitignore | 15 +- hack/release-file-drift.sh | 59 +++ 20 files changed, 1830 insertions(+), 291 deletions(-) create mode 100644 .copier-answers.yml create mode 100644 .github/actions/setup/action.yml create mode 100755 .github/actions/setup/detect.sh create mode 100644 .github/release-please-config.json create mode 100644 .github/release-please-manifest.json create mode 100644 .github/renovate.json delete mode 100644 .github/workflows/build-images.yaml create mode 100644 .github/workflows/ci-build.yml create mode 100644 .github/workflows/ci-integration.yml create mode 100644 .github/workflows/ci-lint.yml create mode 100644 .github/workflows/ci-release.yml create mode 100644 .github/workflows/ci-retag.yml create mode 100644 .github/workflows/ci-smoke.yml create mode 100644 .github/workflows/ci.yaml delete mode 100644 .github/workflows/manual-dev-release-workflow.yaml delete mode 100644 .github/workflows/pull-request-workflow.yaml create mode 100644 .github/workflows/release-republish.yml delete mode 100644 .github/workflows/release.yaml create mode 100755 hack/release-file-drift.sh diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..ff42554 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,14 @@ +# Managed by copier — do not edit by hand. `uvx copier update --trust` +_commit: a676cfe +_src_path: gh:jacaudi/template +build_context: . +extra_build_contexts: [] +has_chart: false +image_name: ghcr.io/jacaudi/wireguard-operator/manager +integration_kind: envtest +lang_go: true +lang_node: false +lang_python: false +repo_name: wireguard-operator +variant: service + diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..26ca583 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,170 @@ +# Installs the language toolchains and lint tools a repo needs, plus Task. +# THE tool-version pin for the whole fleet: ci-lint.yml and `task :ci` both +# invoke bare binaries, so the versions below are the single source of truth. +name: Set up toolchains +description: Install the language toolchains and lint tools present in this repo, plus Task. + +inputs: + repo-token: + description: GITHUB_TOKEN — an input because `secrets` is unavailable inside a composite action. + required: false + default: '' + +# The single reason ci-lint.yml needs no GO_DIRS script: a composite action's +# `steps.*.outputs` do NOT escape without this block, so detect.sh's already- +# computed values — including the SHALLOWEST go.mod, which shallowest() exists +# to find because nested examples/*/go.mod modules occur in this fleet — were +# unreachable to every caller. Callers read them as steps..outputs.. +outputs: + # PUBLISH ONLY WHAT IS CONSUMED. Six were declared originally; after all seven + # stages were built, `go` was the only one any of them read (ci-lint.yml x5), + # so the rest were cut. Adding an output back later is ADDITIVE — a stage that + # does not read it is unaffected — while removing one breaks every stage that + # does. That asymmetry says publish on demand. + # + # `godir` is that demand, and it closes a real defect rather than anticipating + # one. `gomod` drives setup-go's `go-version-file` below, but setup-go only + # installs the TOOLCHAIN; it does not change directory. So in a repo whose + # module is at svc/go.mod, every Go command ci-lint.yml ran afterwards ran in + # the repo root against no module — `go mod tidy -diff` reporting `go.mod file + # not found in current directory or any parent directory`, `go test ./...` + # reporting `directory prefix . does not contain main module`. ci-lint.yml + # now takes this as each Go step's `working-directory:`. + go: + description: 'true when the repo contains a Go module' + value: ${{ steps.d.outputs.go }} + godir: + description: 'directory holding the shallowest go.mod, or "." — the working-directory for module-root Go commands' + value: ${{ steps.d.outputs.godir }} + +runs: + using: composite + steps: + # Detection is a shell step, not inline hashFiles(), for two reasons found by + # running it against real repos: + # - NOTHING is guaranteed to be at the repo root, Go included. A root-only + # check silently skips setup for a module in a subdirectory. + # - `**/Dockerfile*` matches junk like /Dockerfile.json, which + # would install hadolint and then lint a JSON file. + # Detection lives in detect.sh so tests/detect-test.sh exercises the SAME + # code that ships here. Three fail-silent find traps are documented there. + # `env:`, not direct interpolation. `github.action_path` is the runner's own + # value and not attacker-controlled, but scripts/run-interpolation.sh admits + # no exceptions — and actionlint cannot lint a composite action at all, so + # that gate is the only static check this block gets. + - name: Detect repo shape + id: d + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + out=$(bash "${ACTION_PATH}/detect.sh") + printf '%s\n' "${out}" + printf '%s\n' "${out}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: ${{ steps.d.outputs.go == 'true' }} + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ steps.d.outputs.gomod }} + + # A lockfile must exist or setup-node throws on an empty cache hash. + - name: Set up Node + if: ${{ steps.d.outputs.node == 'true' && steps.d.outputs.nodelock == 'true' }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + cache: npm + cache-dependency-path: '**/package-lock.json' + + - name: Set up Node (no lockfile — cache disabled) + if: ${{ steps.d.outputs.node == 'true' && steps.d.outputs.nodelock != 'true' }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + + - name: Set up Python (uv) + if: ${{ steps.d.outputs.python == 'true' }} + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + + - name: Set up Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + with: + version: 3.x + repo-token: ${{ inputs.repo-token }} + + # NONE of the linters below ship on ubuntu-latest — `task ci` cannot pass + # without them. Pinned release tarballs, not a piped install script. + # yamllint IS preinstalled; do not reach for it through uvx (uv is not). + - name: Install actionlint + if: ${{ steps.d.outputs.workflows == 'true' }} + shell: bash + env: + # renovate: datasource=github-releases depName=rhysd/actionlint + VERSION: 1.7.12 + run: | + set -euo pipefail + curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${VERSION}/actionlint_${VERSION}_linux_amd64.tar.gz" \ + | sudo tar xz -C /usr/local/bin actionlint + actionlint --version + + - name: Install golangci-lint + if: ${{ steps.d.outputs.go == 'true' }} + shell: bash + env: + # renovate: datasource=github-releases depName=golangci/golangci-lint + VERSION: 2.12.2 + run: | + set -euo pipefail + curl -sSfL "https://github.com/golangci/golangci-lint/releases/download/v${VERSION}/golangci-lint-${VERSION}-linux-amd64.tar.gz" \ + | sudo tar xz --strip-components=1 -C /usr/local/bin "golangci-lint-${VERSION}-linux-amd64/golangci-lint" + golangci-lint --version + + - name: Install govulncheck + if: ${{ steps.d.outputs.go == 'true' }} + shell: bash + env: + # renovate: datasource=go depName=golang.org/x/vuln + VERSION: v1.1.4 + run: go install "golang.org/x/vuln/cmd/govulncheck@${VERSION}" + + - name: Install hadolint + if: ${{ steps.d.outputs.docker == 'true' }} + shell: bash + env: + # renovate: datasource=github-releases depName=hadolint/hadolint + VERSION: 2.15.1 + run: | + set -euo pipefail + sudo curl -sSfLo /usr/local/bin/hadolint \ + "https://github.com/hadolint/hadolint/releases/download/v${VERSION}/hadolint-Linux-x86_64" + sudo chmod +x /usr/local/bin/hadolint + hadolint --version + + # THE Python tool pin, for the whole repo. The stock template pinned ruff + # inside .taskfiles/python.yml instead — invisible to CI, and under deviation + # D1 CI does not read the taskfile at all, so the two would silently diverge. + # Pinning here means ci-lint.yml and `task python:ci` run the SAME ruff. + # + # `uv tool install` puts them on PATH, so both callers invoke a bare `ruff` / + # `ty` with no version string at the call site — which is what keeps the pin + # single-sourced. + - name: Install ruff and ty + if: ${{ steps.d.outputs.python == 'true' }} + shell: bash + env: + # renovate: datasource=pypi depName=ruff + RUFF_VERSION: 0.16.2 + # renovate: datasource=pypi depName=ty + # Pre-1.0 and moving fast; pinned exactly so a release cannot turn the + # fleet red overnight with nothing to bisect against. + TY_VERSION: 0.0.70 + run: | + set -euo pipefail + uv tool install "ruff==${RUFF_VERSION}" + uv tool install "ty==${TY_VERSION}" + ruff --version + ty --version diff --git a/.github/actions/setup/detect.sh b/.github/actions/setup/detect.sh new file mode 100755 index 0000000..a2756d2 --- /dev/null +++ b/.github/actions/setup/detect.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Detect which languages and artefacts a repo contains. Prints key=value lines. +# +# Shared by action.yml (which appends this to $GITHUB_OUTPUT) and by +# tests/detect-test.sh. ONE implementation so the test cannot drift from what +# actually ships. +# +# Usage: detect.sh [DIR] (defaults to $PWD) +# +# THREE TRAPS ARE ENCODED HERE, each of which returns a confidently empty or +# wrong answer rather than erroring: +# +# 1. `-mindepth 1` is REQUIRED. The basename of the starting point `.` matches +# the `.*` prune glob, so without it find prunes the entire tree and prints +# nothing — every language silently undetected, in every repo. +# +# 2. The Dockerfile extension filter is anchored to `/Dockerfile.`. A blanket +# `\.(json|md|txt)$` also strips every package.json, so Node can never be +# detected. +# +# 3. The caller's expression is wrapped in \( \) inside find_real. A bare `-o` +# binds looser than `-type f` and the `-prune` clause and discards both. +# +# THE DOCKERFILE GLOB IS NOT WRITTEN HERE. It lives in scripts/dockerfile-list.sh, +# which ci-lint.yml's hadolint step reads too. Trap 1's `.*` prune is right for +# every other language marker and WRONG for Dockerfiles, and while this file +# owned its own copy of the glob the two callers disagreed about exactly that: a +# repo with .devcontainer/Dockerfile got docker=false here, so hadolint was never +# installed, while ci-lint.yml found the file anyway and died with exit 127. +set -euo pipefail + +# Resolved BEFORE the cd below, which moves us into the tree under inspection. +# Fail loudly rather than reporting docker=false: a missing capability that +# answers "no Dockerfile" is the fail-silent shape this header already documents +# three instances of. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dockerfile_list="${here}/../../../scripts/dockerfile-list.sh" +[ -f "${dockerfile_list}" ] || { + echo "detect.sh: cannot find ${dockerfile_list}" >&2 + exit 1 +} + +cd "${1:-$PWD}" + +# `testdata` is in the list for a reason the other entries do not share: the Go +# toolchain itself defines it as excluded from the build, so a Go repo's +# testdata/**/package.json is a FIXTURE, not this repo's Node project. Detected +# as one, node=true made the setup action run actions/setup-node with +# `node-version-file: .node-version` — a file copier never wrote, because the +# repo answered lang_node=false — and setup-node fails outright on an absent +# version file. The whole job died before a single linter ran. +# +# The same gap existed in scripts/discover-dirs.sh, chart-list.sh and +# dockerfile-list.sh, which carry this prune list for their own callers. Keep +# the four in step. +pruned=(-name node_modules -o -name vendor -o -name .git -o -name .worktrees -o -name testdata -o -name '.*') +find_real() { + find . -mindepth 1 \( "${pruned[@]}" \) -prune -o -type f \( "$@" \) -print 2>/dev/null +} + +# SHALLOWEST path wins, not the lexically first. A repo with example or test +# modules (examples/analytics/go.mod) would otherwise beat the real ./go.mod, +# because "./e" sorts before "./g" — and go-version-file would then point at a +# nested module's Go directive. +shallowest() { awk '{ n = gsub("/", "/"); print n, $0 }' | sort -k1,1n -k2,2 | head -1 | cut -d' ' -f2-; } + +gomod=$(find_real -name go.mod | shallowest || true) +node=$(find_real -name package.json | sort | head -1 || true) +lock=$(find_real -name package-lock.json | sort | head -1 || true) +py=$(find_real -name pyproject.toml -o -name requirements.txt | sort | head -1 || true) +# NOT find_real. The shared list already emits a sorted answer, and it prunes +# the vendored trees by name instead of pruning every dot-directory — which is +# what lets a .devcontainer/Dockerfile be seen here and by ci-lint.yml alike. +# Containerfile, the OCI/Podman spelling, and the anchored extension filter both +# live there; the reasons they are not optional are in that script's header. +docker=$(sh "${dockerfile_list}" | head -1 || true) +wf=$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) 2>/dev/null | head -1 || true) + +b() { [ -n "$1" ] && echo true || echo false; } + +# EVERY KEY BELOW HAS A CONSUMER, and that is the rule this list is held to — +# action.yml's `outputs:` block and its five `if:` guards are the only readers, +# and tests/detect-test.sh's C1/C3 assert the two sets agree in both directions. +# +# Five keys were emitted here with no reader at all: rust, chart, chartpath, +# dockerfile and variant. Each cost something rather than merely sitting idle — +# `chart`/`chartpath` needed a THIRD copy of the Chart.yaml glob, without the +# subchart exclusion scripts/chart-list.sh exists to provide, and `dockerfile` +# carried a comment claiming ci-build reads it to decide `--file`, which it does +# not: ci-build resolves /Dockerfile then /Containerfile +# itself. Re-adding an output later is additive; publishing one nothing reads is +# a claim that ages into a lie. +echo "gomod=${gomod#./}" +# THE DIRECTORY, not just the file, because they have different consumers. +# setup-go takes `go-version-file: ` and sets the TOOLCHAIN — it does not +# change directory. Every Go command ci-lint.yml runs afterwards is a +# module-root operation, so each takes `working-directory: `; without it +# a module under svc/ left `go mod tidy -diff` and `go test ./...` running in the +# repo root, where they fail with `go.mod file not found` and `directory prefix +# . does not contain main module`. +echo "godir=$(dirname "${gomod:-.}" | sed 's|^\./||')" +echo "go=$(b "${gomod}")" +echo "node=$(b "${node}")" +echo "nodelock=$(b "${lock}")" +echo "python=$(b "${py}")" +echo "docker=$(b "${docker}")" +echo "workflows=$(b "${wf}")" diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 0000000..62078f9 --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "separate-pull-requests": false, + "pull-request-title-pattern": "chore: release ${version}", + "include-component-in-tag": false, + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "deps", + "section": "Dependencies" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Miscellaneous Chores", + "hidden": false + }, + { + "type": "docs", + "section": "Documentation", + "hidden": true + }, + { + "type": "style", + "section": "Styles", + "hidden": true + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": true + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System", + "hidden": true + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "packages": { + ".": { + "release-type": "go", + "include-v-in-tag": true + } + } +} diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json new file mode 100644 index 0000000..a9b8e02 --- /dev/null +++ b/.github/release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.11.0" +} diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..5a0ff15 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>jacaudi/renovate-config:base", + "github>jacaudi/renovate-config:go" + ] +} diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml deleted file mode 100644 index 517b412..0000000 --- a/.github/workflows/build-images.yaml +++ /dev/null @@ -1,115 +0,0 @@ -name: Build Docker Images -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -on: - workflow_call: - inputs: - ref: - required: true - type: string - repository: - description: 'Repository name with owner.' - required: false - default: ${{ github.repository }} - type: string - tag: - required: false - default: dev-${{ github.sha }} - type: string - push: - required: true - type: boolean - latest: - required: false - default: false - type: boolean - upload_images: - required: false - default: false - type: boolean - platforms: - description: 'Docker image platforms' - required: false - default: 'linux/amd64, linux/arm64' - type: string - - -permissions: - contents: read - packages: write - -jobs: - build-images: - strategy: - matrix: - image: - - manager - - agent - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - repository: ${{ inputs.repository }} - ref: ${{ inputs.ref }} - submodules: true - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate docker metadata - id: image-meta - uses: docker/metadata-action@v6 - with: - tags: | - type=raw,value=latest, enable=${{ inputs.latest}} - type=raw,value=${{ inputs.tag }} - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ matrix.image }} - - - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - name: Build and push docker images - if: ${{ inputs.push == true }} - uses: docker/build-push-action@v7 - with: - context: . - file: images/${{ matrix.image }}/Dockerfile - platforms: ${{ inputs.platforms }} - push: true - tags: ${{ steps.image-meta.outputs.tags }} - labels: ${{ steps.image-meta.outputs.labels }} - - - name: Build and upload docker image to job artifact - if: ${{ inputs.upload_images == true }} - uses: docker/build-push-action@v7 - with: - context: . - file: images/${{ matrix.image }}/Dockerfile - platforms: ${{ inputs.platforms }} - outputs: type=docker,dest=/tmp/${{matrix.image}}.tar - push: false - tags: ${{ steps.image-meta.outputs.tags }} - labels: ${{ steps.image-meta.outputs.labels }} - - - name: Upload artifact - if: ${{ inputs.upload_images == true }} - uses: actions/upload-artifact@v7 - with: - name: image-${{ matrix.image }} - path: /tmp/${{matrix.image}}.tar diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 0000000..8ce4926 --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,225 @@ +# Stage 2 — multi-arch image to GHCR, output pinned by DIGEST; `version` + `ref` +# drive release builds. +# +# NEVER MATRIX THIS STAGE. It publishes `outputs.image`, and a matrixed job's +# outputs are last-leg-wins with no guaranteed ordering, so smoke and retag +# would silently receive the wrong digest. Use ci-build-check.yml for auxiliary +# images — it declares no outputs precisely so it CAN be matrixed. +name: ci-build + +on: + workflow_call: + inputs: + version: + description: Release tag to derive semver image tags from (e.g. v1.2.3). Empty for non-release builds. + required: false + default: '' + type: string + ref: + description: Git ref to build. Empty builds the triggering commit; a release build must pass its tag. + required: false + default: '' + type: string + image: + description: Image name. Defaults to ghcr.io//. + required: false + default: '' + type: string + # NO `platforms:` INPUT, deliberately. Every container-shipping repo in + # this fleet builds both arches, and a knob no caller ever sets is one + # more input every repo carries. + # + # `context` is NOT cut with it: kiwix-helm-chart has no root Dockerfile + # — it is at image/Dockerfile — so a hardcoded `.` makes that repo fail + # with "no Dockerfile or Containerfile in .". + context: + description: Docker build context. + required: false + default: . + type: string + # LOCAL DEVIATION FROM THE TEMPLATE — `copier update` will conflict here, + # and the resolution is to keep this input. + # + # Upstream cut `file:` on the reasoning that a repo whose Dockerfile is + # elsewhere moves its `context:` instead. That resolution assumes the + # Dockerfile and the build context can be the same directory, and this + # repo is a counter-example: it publishes TWO images, images/manager and + # images/agent, and BOTH `COPY go.mod cmd api internal` — so both need the + # repo root as context while neither sits at it. `context: .` finds no + # ./Dockerfile; `context: images/manager` finds one but fails at the COPY. + # There is no answer to `build_context` that builds either image. + # + # extra_build_contexts is not the escape hatch either: ci.yaml's + # build-check matrix passes only `context:`, so it carries the identical + # constraint — and ci-build-check.yml never pushes. + # + # The block below is ci-build-check.yml's resolution logic verbatim, which + # already ships this exact input for the same reason. This is the template's + # own pattern, not a new one; it is only absent from the stage that + # publishes. Worth upstreaming so the deviation can be dropped. + file: + description: >- + Dockerfile path. Empty auto-resolves /Dockerfile then + /Containerfile, and fails loudly if neither exists. + required: false + default: '' + type: string + manifest-file: + description: >- + release-please manifest, read to verify a release build is stamped from + the matching checkout. Must match ci.yaml's `manifest-file:` input. + required: false + default: .github/release-please-manifest.json + type: string + secrets: + APP_ID: + description: GitHub App id. Required only to push outside this repo's own GHCR namespace. + required: false + APP_PRIVATE_KEY: + description: Private key for the above App + required: false + outputs: + image: + description: Pushed image reference, pinned by digest + value: ${{ jobs.build.outputs.image }} + +permissions: + contents: read + packages: write + +jobs: + build: + name: build + push image + runs-on: ubuntu-latest + # A CEILING, not an estimate. Without one the job inherits GitHub's + # 360-minute default, so a step that hangs — a registry that never answers, + # a QEMU build that livelocks — burns six hours of a runner before anyone + # is told. This is the slowest stage in the fleet (multi-arch, arm64 under + # emulation) and 60 is still well clear of any observed build. + timeout-minutes: 60 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.ref.outputs.image }} + env: + APP_ID: ${{ secrets.APP_ID }} + # GHCR rejects uppercase refs; owner/repo casing is not guaranteed. + IMAGE: ${{ inputs.image != '' && inputs.image || format('ghcr.io/{0}', github.repository) }} + steps: + - name: Normalise the image name to lowercase + run: echo "IMAGE=$(printf '%s' "${IMAGE}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + # buildx defaults to /Dockerfile and does NOT know about + # Containerfile, so an OCI-named repo fails with + # failed to read dockerfile: open Dockerfile: no such file or directory + # Resolve it here rather than making every caller pass `file:`. + # + # An explicit `file:` wins; otherwise resolution is by convention, and a + # miss is a loud failure either way. See the `file:` input above for why + # this repo needs the override that upstream cut. + - name: Resolve the Dockerfile / Containerfile + env: + CONTEXT: ${{ inputs.context }} + FILE: ${{ inputs.file }} + run: | + set -euo pipefail + if [ -n "${FILE}" ]; then + [ -f "${FILE}" ] || { echo "::error::file input '${FILE}' does not exist"; exit 1; } + resolved="${FILE}" + elif [ -f "${CONTEXT}/Dockerfile" ]; then + resolved="${CONTEXT}/Dockerfile" + elif [ -f "${CONTEXT}/Containerfile" ]; then + resolved="${CONTEXT}/Containerfile" + else + echo "::error::no Dockerfile or Containerfile in ${CONTEXT}" + exit 1 + fi + echo "building from ${resolved}" + echo "DOCKERFILE=${resolved}" >> "$GITHUB_ENV" + + - name: Verify the checkout matches the release being stamped + if: ${{ inputs.version != '' }} + env: + WANT: ${{ inputs.version }} + # Kept in step with ci.yaml's `manifest-file:` input. Both default to + # .github/; a repo that keeps the manifest at the root overrides here + # and there together. + MANIFEST: ${{ inputs.manifest-file }} + run: | + set -euo pipefail + if [ ! -f "${MANIFEST}" ]; then + echo "::error::${MANIFEST} missing — cannot verify ${WANT}" + exit 1 + fi + have="v$(jq -r '.["."]' "${MANIFEST}")" + echo "tag being stamped: ${WANT}" + echo "manifest reports: ${have}" + [ "${WANT}" = "${have}" ] || { echo "::error::refusing to publish ${WANT} from a checkout that reports ${have}"; exit 1; } + + - name: Generate a GitHub App token + id: app-token + if: ${{ env.APP_ID != '' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + flavor: | + latest=false + tags: | + type=sha + type=raw,priority=1000,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern=v{{version}},value=${{ inputs.version }} + type=semver,pattern=v{{major}}.{{minor}},value=${{ inputs.version }} + type=semver,pattern=v{{major}},value=${{ inputs.version }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ steps.app-token.outputs.token && 'x-access-token' || github.actor }} + password: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: ${{ inputs.context }} + file: ${{ env.DOCKERFILE }} + pull: true + push: true + # The literal the cut `platforms:` input always defaulted to. + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=min + provenance: mode=min # NOT max — max embeds build-args and secrets in the attestation + + # `env:`, not direct interpolation. A build digest is not attacker + # controlled, but scripts/run-interpolation.sh admits no exceptions on + # purpose: a reviewer cannot tell the safe interpolations from the unsafe + # ones at a glance, and a rule with exceptions is a rule nobody applies. + - name: Expose image reference (by digest) + id: ref + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: echo "image=${IMAGE}@${DIGEST}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml new file mode 100644 index 0000000..f174fc9 --- /dev/null +++ b/.github/workflows/ci-integration.yml @@ -0,0 +1,84 @@ +# Integration tests that need job-level infrastructure. +# +# WHY THIS IS A STAGE OF ITS OWN AND NOT PART OF ci-lint: `services:` and +# envtest asset setup are job-level concerns. A composite action cannot declare +# a `services:` block, a called workflow declares its own, and the caller can +# neither inject nor override it. So a repo needing one gets this stage, wired +# into the `ci` gate in ci.yaml. +# +# Copier writes this file only when integration_kind is not `none`, and emits the +# matching `integration:` job in ci.yaml under the same condition. The two move +# together or the run fails on a `uses:` that points at nothing. +name: integration + +on: + workflow_call: + +permissions: + contents: read + +jobs: + integration: + name: integration tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + # envtest runs a real kube-apiserver and etcd from downloaded binaries. + # setup-envtest resolves and caches them; KUBEBUILDER_ASSETS is how + # controller-runtime finds them, and the suite FAILS LOUDLY without it + # rather than skipping, which is the behaviour you want. + - name: Install setup-envtest + env: + # THE TEMPLATE'S STOCK v0.22.1 DOES NOT RESOLVE — verified, and it + # would fail this step in any consumer, not just here: + # go: sigs.k8s.io/controller-runtime/tools/setup-envtest@v0.22.1: + # module sigs.k8s.io/controller-runtime@v0.22.1 found, but does not + # contain package .../tools/setup-envtest + # tools/setup-envtest is a SEPARATE MODULE with its own tag line, and + # its only released versions are v0.24.0 and v0.24.1. A controller- + # runtime version number is not a valid version for it. Worth reporting + # upstream. + # + # The tool version is also independent of the library version: this is + # a downloader for apiserver/etcd binaries. What has to match this + # repo's k8s.io/* v0.30 client stack is the ASSET version below, not + # this. Verified: v0.24.1 fetches 1.30.0 assets and the suite passes + # against controller-runtime v0.18.5. + # + # The stock renovate annotation named controller-runtime, which is the + # wrong module for this pin and would propose versions that cannot + # install. + # renovate: datasource=go depName=sigs.k8s.io/controller-runtime/tools/setup-envtest + SETUP_ENVTEST_VERSION: v0.24.1 + run: go install "sigs.k8s.io/controller-runtime/tools/setup-envtest@${SETUP_ENVTEST_VERSION}" + + - name: Resolve envtest assets + env: + # Pin the control-plane version deliberately: `use latest` makes CI + # non-reproducible and lets an upstream k8s release turn the fleet red + # overnight with nothing to bisect against. + # + # 1.30.0, not the template's stock 1.34.x — this repo's client stack is + # k8s.io/* v0.30.x behind controller-runtime v0.18.5. THIS NUMBER HAS + # THREE COPIES that no tool can share: here, Makefile's + # ENVTEST_K8S_VERSION, and the fallback in .taskfiles/go.yml's + # `integration` task. Change one, change all three. + ENVTEST_K8S_VERSION: '1.30.0' + run: | + set -euo pipefail + path=$(setup-envtest use "${ENVTEST_K8S_VERSION}" --bin-dir "${RUNNER_TEMP}/envtest" -p path) + echo "KUBEBUILDER_ASSETS=${path}" >> "$GITHUB_ENV" + echo "resolved: ${path}" + + - name: go test (envtest) + run: go test ./... -race -tags=integration diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml new file mode 100644 index 0000000..429a0cf --- /dev/null +++ b/.github/workflows/ci-lint.yml @@ -0,0 +1,353 @@ +# Stage 1 — static analysis AND unit tests. Runs each tool as its OWN STEP. +# +# DEVIATION D1: this stage does NOT call `task ci`. Each tool is invoked +# directly so GitHub can attribute annotations, timing, and failures to the +# specific check that produced them, instead of collapsing everything into one +# opaque `task ci` step. +# +# The knowledge is still single-sourced — the RULES live in the config files +# (.golangci.yml, .yamllint.yml, .hadolint.yaml), which both this workflow and +# the consumer's taskfile.yml read. Tool VERSIONS live once, in +# .github/actions/setup. Only the invocation is duplicated, and an invocation is +# shared shape, not shared knowledge. +# +# Keep the consumer's taskfile.yml targets equivalent to the steps below, so +# `task ci` locally still predicts what CI will say. +name: ci-lint + +on: + workflow_call: + inputs: + go-test-flags: + description: Extra flags for `go test`. Override for stricter scheduled runs. + required: false + default: '-race' + type: string + +permissions: + contents: read + +jobs: + lint: + name: lint + unit tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # 0, not 2. Depth 2 only sees the last commit, so a push whose lint- + # relevant edit was not the final commit silently skipped the check. + fetch-depth: 0 + + - name: Set up toolchains + id: setup + uses: ./.github/actions/setup + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # --- workflow integrity ------------------------------------------------- + # FIRST, and unconditional. actionlint has a verified blind spot: a bogus + # INPUT to a local composite action is caught, but `uses: ./path` where the + # path does not exist is SILENT. Every stage this repo runs is a local + # reference, so a stage file or .github/actions/setup/ that copier did not + # write passes lint cleanly and fails only at run time, on main. + # Quoted: an unquoted value containing a colon is a YAML syntax error. + - name: "Verify local uses: references resolve" + run: sh scripts/local-refs.sh + + # The other two static gates that ship with this repo. Without these steps + # they are dead files in every consumer: nothing else invokes them. + # run-scripts-exist.sh - every repo-relative script named in a run + # step must exist. Catches a rename that left a caller behind. + # run-interpolation.sh - no expression may be pasted straight into a + # shell body, where a crafted input would reach bash. + - name: Verify every run step names a real script + run: sh scripts/run-scripts-exist.sh + + - name: Verify no expression is interpolated into a shell body + run: sh scripts/run-interpolation.sh + + # Surfaces drift between the files copier wrote here and the template they + # came from. Warns, never fails — see scripts/copier-freshness.sh for why. + # GH_TOKEN is required: the script resolves the template's latest release + # through `gh api`, and without it degrades to "freshness unknown" rather + # than failing. + - name: Check copier template freshness + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: sh scripts/copier-freshness.sh + + # Unconditional, unlike the taskfile version which skips when no workflow + # changed. That optimisation exists for local dev; actionlint takes ~2s and + # the conditional is not worth its complexity in CI. + - name: actionlint + run: actionlint + + # --- repo-level --------------------------------------------------------- + - name: yamllint + run: yamllint . + + # THE SAME LIST .github/actions/setup/detect.sh reads, and that one decides + # whether hadolint is installed at all. While this step carried its own + # copy of the glob the two disagreed about dot-directories: a repo with + # .devcontainer/Dockerfile got docker=false from the detector — so hadolint + # was never installed — and this find handed that same file to it anyway, + # killing the step with `hadolint: command not found`, exit 127, blaming a + # Dockerfile that was fine. Never re-inline the glob; see + # scripts/dockerfile-list.sh for what it excludes and why. + - name: hadolint + run: | + set -euo pipefail + files=$(sh scripts/dockerfile-list.sh) + [ -n "${files}" ] || { echo "no Dockerfile/Containerfile — skipping"; exit 0; } + # NUL-delimited, NOT a bare `xargs`: xargs splits on ANY whitespace, so + # a path like `my images/Dockerfile` became two arguments and hadolint + # failed on two files nobody wrote. + printf '%s\n' "${files}" | tr '\n' '\0' | xargs -0 hadolint + + # --- Go ----------------------------------------------------------------- + # Checks FAIL on drift; they never fix it. A CI step that mutates the tree + # hides the problem it was meant to surface. + # + # A presence guard, NOT a loop: `go mod tidy -diff`, `golangci-lint run` + # and `govulncheck ./...` are all module-root operations, and detect.sh has + # already picked the shallowest go.mod. + # + # EVERY GO STEP CARRIES `working-directory`, AND THAT IS NOT COSMETIC. + # setup-go was given `go-version-file: `, which + # installs a TOOLCHAIN and does nothing else — it does not cd. Nothing here + # set a working directory at all, so in a repo whose module is at svc/ all + # five steps ran in the repo root and failed together: `go mod tidy -diff` + # with `go.mod file not found in current directory or any parent + # directory`, `go test ./...` with `directory prefix . does not contain + # main module`. Measured, not inferred. + # + # `.` for the ordinary root-module repo, so the common case is unchanged. + # .golangci.yml stays at the REPO root either way: golangci-lint searches + # upward from its working directory, the same way ruff finds ruff.toml. + # + # tests/stage-shape-test.sh asserts this on every step matching the `go` + # guard, so a Go step added later cannot quietly omit it. + - name: gofmt / goimports check + if: steps.setup.outputs.go == 'true' + working-directory: ${{ steps.setup.outputs.godir }} + run: golangci-lint fmt --diff + + - name: go mod tidy check + if: steps.setup.outputs.go == 'true' + working-directory: ${{ steps.setup.outputs.godir }} + # -diff exits non-zero on drift. A bare `go mod tidy` MUTATES go.mod in CI. + run: go mod tidy -diff + + - name: golangci-lint + if: steps.setup.outputs.go == 'true' + working-directory: ${{ steps.setup.outputs.godir }} + run: golangci-lint run + + - name: govulncheck + if: steps.setup.outputs.go == 'true' + working-directory: ${{ steps.setup.outputs.godir }} + run: govulncheck ./... + + # `env:`, not direct interpolation: a published `workflow_call` input must + # never be pasted straight into a shell command. + - name: go test + if: steps.setup.outputs.go == 'true' + working-directory: ${{ steps.setup.outputs.godir }} + env: + GO_TEST_FLAGS: ${{ inputs.go-test-flags }} + run: | + # shellcheck disable=SC2086 # deliberate word-splitting of the flags + go test ./... ${GO_TEST_FLAGS} + + # --- generated-file drift (kubebuilder; no template equivalent) ---------- + # REPO-SPECIFIC ADDITIONS. The template ships a drift gate only for Helm + # charts (scripts/chart-drift.sh, below). This is a kubebuilder operator: + # its CRDs, RBAC and deepcopy code are generated from the API types and + # COMMITTED, so they can fall behind those types with nothing to say so — + # and every consumer then installs a schema that does not match. + # + # Both gates FAIL on drift and never fix it. A CI step that mutates the + # tree hides the problem it exists to surface. + - name: Regenerate CRDs, RBAC and deepcopy + # `make`, not a bare controller-gen invocation: the Makefile already + # pins CONTROLLER_TOOLS_VERSION and owns the exact generator flags, so + # calling it keeps one source of truth instead of a second copy here + # that can drift from what a developer runs locally. + run: make manifests generate + + - name: Assert generated files are up to date + run: | + set -euo pipefail + if ! git diff --exit-code -- config api; then + echo "::error::generated files are out of date" + echo "::error::run 'make manifests generate' and commit the result" + exit 1 + fi + echo "generated files match the API types" + + # release.yaml is the documented install path and is ALSO generated — from + # config/, by kustomize. See hack/release-file-drift.sh for why the image + # pins are read back out of the file instead of being passed in. + - name: Verify release.yaml is not stale + run: sh hack/release-file-drift.sh + + # --- Helm (chart repos only; a no-op otherwise) -------------------------- + - name: Resolve chart dependencies + # Same script ci-chart.yml runs, so CI and local resolve identically. + # `helm dependency build` honours Chart.lock; `update` would rewrite it. + # Required once the vendored charts/*.tgz is deleted, or the template + # step below cannot resolve its subcharts. + run: sh scripts/chart-deps.sh + + - name: Verify the chart is not stale + # DRIFT GATE. Operator charts derive their CRDs and RBAC from config/, so + # a committed chart can silently fall behind the API types. The script + # regenerates and then FAILS on any diff — it never fixes it, because a + # CI step that mutates the tree hides the problem it exists to surface. + # A repo with no hack/ generators has a hand-maintained chart and skips. + run: sh scripts/chart-drift.sh + + - name: helm lint + template + run: | + set -euo pipefail + charts=$(sh scripts/chart-list.sh) + [ -n "${charts}" ] || { echo "no chart — skipping"; exit 0; } + # Split on NEWLINE ONLY, so a chart directory containing a space + # survives as one argument instead of becoming two bogus paths. + oldifs=$IFS + IFS=' + ' + # shellcheck disable=SC2086 # deliberate newline-only split, per IFS above + set -- ${charts} + IFS=$oldifs + for d in "$@"; do + helm lint "${d}" + # `template` as well as `lint`: lint catches schema and metadata + # problems, but only a full render catches a template that throws on + # default values. + helm template release-test "${d}" >/dev/null + done + + # --- discover project directories --------------------------------------- + # NOTHING here hardcodes a project directory — that is what lets one + # byte-identical file serve every repo. The logic lives in a script so a + # shell harness can test it; see scripts/discover-dirs.sh. + - name: Discover project directories + run: | + set -euo pipefail + # NOT `... | tee -a "$GITHUB_ENV"`. The default shell is `bash -e` + # WITHOUT pipefail, so the step's status would be tee's and a failing + # discover-dirs.sh would exit 0 with PY_DIRS/JS_DIRS unset — every + # Python and JS step then prints "no … project — skipping". That + # converts a loud failure into the fail-silent shape this design + # exists to remove. + sh scripts/discover-dirs.sh > "${RUNNER_TEMP}/dirs.env" + cat "${RUNNER_TEMP}/dirs.env" + cat "${RUNNER_TEMP}/dirs.env" >> "$GITHUB_ENV" + + # --- Python ------------------------------------------------------------- + # One STEP per tool, looping over the discovered dirs — so a failure still + # says which tool broke, which is the whole point of deviation D1. The + # ::group:: markers say which directory. + # + # Bare `ruff` / `ty`: versions are pinned once in .github/actions/setup, + # which is also what `task python:ci` uses. Do not add a version here. + # + # ruff.toml lives at the REPO ROOT and ruff finds it by walking up from + # each project — verified. A project needing different rules drops its own + # ruff.toml with `extend = "../ruff.toml"`. + - name: ruff check + run: | + set -euo pipefail + [ -n "${PY_DIRS}" ] || { echo "no Python project — skipping"; exit 0; } + for d in ${PY_DIRS}; do echo "::group::ruff check ${d}"; (cd "${d}" && ruff check .); echo "::endgroup::"; done + + - name: ruff format check + run: | + set -euo pipefail + [ -n "${PY_DIRS}" ] || exit 0 + for d in ${PY_DIRS}; do echo "::group::ruff format ${d}"; (cd "${d}" && ruff format --check .); echo "::endgroup::"; done + + - name: python typecheck + run: | + set -euo pipefail + [ -n "${PY_DIRS}" ] || exit 0 + for d in ${PY_DIRS}; do + echo "::group::ty ${d}" + ( cd "${d}" + # The deps must be IN ty's environment or every third-party import + # reports unresolved and the check is worthless rather than absent. + if [ -f pyproject.toml ]; then ty check . + else uvx --with-requirements requirements.txt ty check .; fi ) + echo "::endgroup::" + done + + - name: pytest + run: | + set -euo pipefail + [ -n "${PY_DIRS}" ] || exit 0 + for d in ${PY_DIRS}; do + ( cd "${d}" + if [ ! -d tests ] && [ ! -d test ]; then + echo "::warning::${d} has no tests/ — no Python tests are running"; exit 0 + fi + echo "::group::pytest ${d}" + # --no-project: with no pyproject.toml, uv otherwise walks UP the + # tree and can bind to an unrelated project root. + if [ -f pyproject.toml ]; then uv run --with pytest pytest + else uv run --no-project --with-requirements requirements.txt --with pytest pytest; fi + echo "::endgroup::" ) + done + + # --- JS/TS -------------------------------------------------------------- + # No version pins: eslint and typescript are devDependencies, so `npm ci` + # pins them from package-lock.json — already a single source. + # + # eslint.config.* MUST sit beside package.json, unlike ruff.toml: a flat + # config `import`s its plugins, and Node resolves those relative to the + # config file, so it has to live where that project's node_modules is. + - name: npm ci + run: | + set -euo pipefail + [ -n "${JS_DIRS}" ] || { echo "no JS/TS project — skipping"; exit 0; } + # `ci`, not `install` — fails on a stale lockfile instead of rewriting it + for d in ${JS_DIRS}; do echo "::group::npm ci ${d}"; (cd "${d}" && npm ci); echo "::endgroup::"; done + + - name: eslint + run: | + set -euo pipefail + [ -n "${JS_DIRS}" ] || exit 0 + for d in ${JS_DIRS}; do echo "::group::eslint ${d}"; (cd "${d}" && npx eslint --max-warnings 0 .); echo "::endgroup::"; done + + - name: tsc + run: | + set -euo pipefail + [ -n "${JS_DIRS}" ] || exit 0 + for d in ${JS_DIRS}; do + ( cd "${d}" + [ -f tsconfig.json ] || { echo "${d}: no tsconfig.json — skipping"; exit 0; } + echo "::group::tsc ${d}" + # `tsc -b`, NOT `tsc --noEmit`. A solution-style tsconfig — the shape + # Vite's React+TS starter generates — is {"files": [], "references": [...]}. + # --noEmit checks the EMPTY file list, does not follow references, and + # exits 0: a green tick over zero type checking. Build mode follows them. + if grep -q '"references"' tsconfig.json; then npx tsc -b --force + else npx tsc --noEmit; fi + echo "::endgroup::" ) + done + + - name: js tests + run: | + set -euo pipefail + [ -n "${JS_DIRS}" ] || exit 0 + for d in ${JS_DIRS}; do + ( cd "${d}" + # `npm pkg get` is stable API; parsing `npm run` output is not. + if [ "$(npm pkg get scripts.test)" = "{}" ]; then + echo "::warning::${d} has no npm test script — no JS tests are running"; exit 0 + fi + echo "::group::npm test ${d}"; npm test; echo "::endgroup::" ) + done diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml new file mode 100644 index 0000000..ba33ac4 --- /dev/null +++ b/.github/workflows/ci-release.yml @@ -0,0 +1,154 @@ +# Stage 4 — release-please. +# +# Ordinary main push : opens/updates the release PR (CHANGELOG + version bump) +# release-PR merge : creates the tag and the GitHub Release (release notes) +# +# A STAGE RATHER THAN AN INLINE JOB, even though every file here is copied in +# anyway. The `workflow_call` interface earns its keep on the chart half: the +# "Resolve chart outputs" step below is real logic — release-please prefixes a +# package's outputs with `--`, and getting that wrong is the defect where +# a chart repo's release job never fires and charts silently never publish. +# Inlining it would put that branch in ci.yaml, which is otherwise a pure job +# graph with no implementation in it at all. +# +# NO CHECKOUT, deliberately. release-please drives the repo entirely through the +# API and this stage calls no script, so there is nothing for a checkout to +# provide. +# +# The `main`-only gate lives in the CALLER's `if:`, not here. Hardcoding +# `github.ref == 'refs/heads/main'` would make this stage silently do nothing +# anywhere else, which is not a decision a stage gets to make for the repo. +name: ci-release + +on: + workflow_call: + inputs: + config-file: + description: release-please config, repo-relative. + required: false + default: .github/release-please-config.json + type: string + manifest-file: + description: release-please manifest, repo-relative. Must match ci-build's manifest-file. + required: false + default: .github/release-please-manifest.json + type: string + chart-package: + description: >- + release-please package path for the chart, e.g. `charts`. Empty means the + repo ships no chart. Multi-package release-please prefixes a package's + outputs with `--`, so this is what resolves `charts--version`. + required: false + default: '' + type: string + secrets: + APP_ID: + description: GitHub App id. REQUIRED — there is deliberately no GITHUB_TOKEN fallback. + required: true + APP_PRIVATE_KEY: + description: Private key for the above App. + required: true + outputs: + # Block style, not aligned flow mappings: `{ a: 1, b: 2 }` with padding trips + # yamllint's braces/colons/commas rules and fails the repo's own gate. + release_created: + description: 'true when a release was cut' + value: ${{ jobs.release.outputs.release_created }} + tag_name: + description: 'Release tag, e.g. v1.2.3' + value: ${{ jobs.release.outputs.tag_name }} + version: + description: 'Bare version, e.g. 1.2.3' + value: ${{ jobs.release.outputs.version }} + major: + description: 'Major component, for the major tag' + value: ${{ jobs.release.outputs.major }} + chart_release_created: + description: 'true when the chart package released' + value: ${{ jobs.release.outputs.chart_release_created }} + chart_version: + description: 'Chart version, BARE semver' + value: ${{ jobs.release.outputs.chart_version }} + +# What the CALLER must grant. A called workflow can never exceed the calling job's +# grant, so this block is the contract, not a suggestion: a caller that omits +# `pull-requests: write` gets a release-PR push that fails on permissions. +permissions: + contents: write + pull-requests: write + +jobs: + release: + name: release-please + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + version: ${{ steps.rp.outputs.version }} + major: ${{ steps.rp.outputs.major }} + chart_release_created: ${{ steps.chart.outputs.created }} + chart_version: ${{ steps.chart.outputs.version }} + steps: + # NO `if: APP_ID != ''` guard and NO GITHUB_TOKEN fallback — deliberate, and + # the one thing in this file that must never be "improved". + # GITHUB_TOKEN cannot trigger workflows, so a silent fallback opens a + # release PR that never gets a run, never reports `ci`, and is therefore + # unmergeable FOREVER with no error anywhere explaining why. Letting this + # action fail on an empty app-id turns that into a loud, immediate failure + # at the right place. Both secrets are declared mandatory for the same reason. + - name: Generate a GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Run release-please + id: rp + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + # Paths INSIDE the config (packages, extra-files) stay relative to the + # REPO ROOT, not to the config file. Moving the config does not + # reinterpret them. + # + # ci-build.yml reads the manifest directly to verify a release build; if + # a consumer moves these, it moves ci-build's `manifest-file:` too. + config-file: ${{ inputs.config-file }} + manifest-file: ${{ inputs.manifest-file }} + + # Resolving the `--` prefix here removes a copy-paste trap from every + # consumer: the mandated chart path is `charts/`, so the prefix is + # `charts--`, not the `chart--` the reference's comment showed. + # + # It FAILS LOUDLY when chart-package names a package the config does not + # declare. That is the defect where a chart repo's release-chart job never + # fires and charts silently never publish. + # + # GUARDED ON releases_created. release-please sets per-path outputs ONLY + # when it actually cuts a release; on an ordinary main push it opens or + # updates the release PR and sets none. Without this guard the step cannot + # tell "config declares no such package" (the defect worth failing on) from + # "no release this run" (the normal case), and EVERY chart repo goes red on + # EVERY push to main. + - name: Resolve chart outputs + id: chart + if: ${{ inputs.chart-package != '' && steps.rp.outputs.releases_created == 'true' }} + env: + PKG: ${{ inputs.chart-package }} + CONFIG: ${{ inputs.config-file }} + ALL: ${{ toJSON(steps.rp.outputs) }} + run: | + set -euo pipefail + created=$(printf '%s' "${ALL}" | jq -r --arg k "${PKG}--release_created" '.[$k] // empty') + version=$(printf '%s' "${ALL}" | jq -r --arg k "${PKG}--version" '.[$k] // empty') + if [ -z "${created}" ] && [ -z "${version}" ]; then + echo "::error::a release was cut, but chart-package '${PKG}' produced no release-please outputs." + echo "::error::Add a \"${PKG}\" package to ${CONFIG}, or unset chart-package." + exit 1 + fi + { echo "created=${created:-false}"; echo "version=${version}"; } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci-retag.yml b/.github/workflows/ci-retag.yml new file mode 100644 index 0000000..2f8cf9f --- /dev/null +++ b/.github/workflows/ci-retag.yml @@ -0,0 +1,166 @@ +# Release stage — apply the release tags to the digest ci-build already produced. +# +# A registry-side manifest copy, NOT a rebuild. Two reasons: +# 1. release-please tags the release PR's merge commit, which is the exact +# commit ci-build built in this same run. Rebuilding produces a second +# multi-arch build of identical source. +# 2. A rebuild yields a NEW digest that nothing has smoke-tested. Retagging +# publishes the same bytes that already passed, which a rebuild cannot +# guarantee — container builds are not bit-reproducible. +# +# `docker buildx imagetools create` copies the whole multi-arch manifest LIST. +# `docker pull && docker tag && docker push` would collapse it to the runner's +# single architecture and silently drop arm64. +name: ci-retag + +on: + workflow_call: + inputs: + source: + description: Digest-pinned image reference to retag (from ci-build). + required: true + type: string + version: + description: Release tag to apply (e.g. v1.2.3). + required: true + type: string + image: + description: Image name. Defaults to ghcr.io//. + required: false + default: '' + type: string + secrets: + APP_ID: + description: GitHub App id. Required only to push outside this repo's own GHCR namespace. + required: false + APP_PRIVATE_KEY: + description: Private key for the above App + required: false + outputs: + image: + description: The released image reference + value: ${{ jobs.retag.outputs.image }} + +permissions: + contents: read + packages: write + +jobs: + retag: + name: retag ${{ inputs.version }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.out.outputs.image }} + env: + APP_ID: ${{ secrets.APP_ID }} + SOURCE: ${{ inputs.source }} + TAG: ${{ inputs.version }} + # GHCR rejects uppercase refs; owner/repo casing is not guaranteed. + IMAGE: ${{ inputs.image != '' && inputs.image || format('ghcr.io/{0}', github.repository) }} + steps: + - name: Normalise the image name to lowercase + run: echo "IMAGE=$(printf '%s' "${IMAGE}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + # THE GUARD. Retagging is only correct if the tag names the commit this + # run built. If main moved, or release-please tagged something unexpected, + # publishing build's digest as this version would ship the wrong tree. + # Fail loudly rather than assume — the whole optimisation rests on this. + - name: Assert the tag points at this run's commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" --jq '.object.sha') + type=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" --jq '.object.type') + # An annotated tag points at a tag object, which points at the commit. + if [ "${type}" = "tag" ]; then + ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${ref}" --jq '.object.sha') + fi + echo "tag ${TAG} -> ${ref}" + echo "this run -> ${GITHUB_SHA}" + [ "${ref}" = "${GITHUB_SHA}" ] || { + echo "::error::${TAG} points at ${ref}, not this run's ${GITHUB_SHA} — refusing to retag." + echo "::error::Publish with release-republish.yml (rebuild mode) after investigating." + exit 1 + } + + - name: Generate a GitHub App token + id: app-token + if: ${{ env.APP_ID != '' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ steps.app-token.outputs.token && 'x-access-token' || github.actor }} + password: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + # Mirrors ci-build's docker/metadata-action tag set for the release case: + # v1.2.3, v1.2, v1, latest + - name: Derive the tag set + id: tags + run: | + set -euo pipefail + v="${TAG#v}" + # A REAL SEMVER PATTERN, not a shape check. `case "${v}" in *.*.*)` + # merely asked for two dots somewhere, so `1.2.3$(id -un)` satisfied it + # — along with every other shell metacharacter a caller cares to send + # through the `version` input — and went on to be used as a tag. The + # tag set derived below (vX.Y, vX, latest) is meaningless for anything + # but three plain numeric parts, so demanding exactly that costs + # nothing and is the honest bound. Prereleases are rejected on purpose: + # moving `latest` to an rc is not a thing this stage should do quietly. + printf '%s' "${v}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || { + echo "::error::${TAG} is not a three-part semver tag"; exit 1 + } + major="${v%%.*}" + rest="${v#*.}" + minor="${rest%%.*}" + { + echo "list=v${v} v${major}.${minor} v${major} latest" + } >> "$GITHUB_OUTPUT" + echo "will tag: v${v} v${major}.${minor} v${major} latest" + + # `env:`, NOT direct interpolation — the rule ci-lint.yml states and + # scripts/run-interpolation.sh now enforces. This step used to write + # `for t in ${{ steps.tags.outputs.list }}`, and GitHub substitutes an + # expression into the script as TEXT before any shell sees it, so the + # list's contents became shell source. That list derives from the + # `version` input, which a consumer supplies. Through `env:` it arrives as + # data instead. + - name: Copy the manifest list to the release tags + id: out + env: + LIST: ${{ steps.tags.outputs.list }} + run: | + set -euo pipefail + args=() + # shellcheck disable=SC2086 # deliberate word split over the tag list + for t in ${LIST}; do + args+=(--tag "${IMAGE}:${t}") + done + docker buildx imagetools create "${args[@]}" "${SOURCE}" + echo "image=${IMAGE}:${TAG}" >> "$GITHUB_OUTPUT" + + - name: Confirm the released digest matches what was tested + run: | + set -euo pipefail + want="${SOURCE##*@}" + # The Go template below is single-quoted and uses bare double-braces. + # GitHub only interpolates the dollar-prefixed form, so this reaches + # docker intact. Do NOT write the dollar-prefixed form in a comment + # here either: `run:` blocks are expression-substituted in full, + # comments included, so an empty one is a parse error. (actionlint + # caught exactly that here.) + got=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "${IMAGE}:${TAG}") + echo "tested : ${want}" + echo "shipped: ${got}" + [ "${want}" = "${got}" ] || { echo "::error::released digest differs from the tested one"; exit 1; } diff --git a/.github/workflows/ci-smoke.yml b/.github/workflows/ci-smoke.yml new file mode 100644 index 0000000..c617382 --- /dev/null +++ b/.github/workflows/ci-smoke.yml @@ -0,0 +1,69 @@ +# Stage 3 — thin harness: runs `task smoke` with IMAGE set; the CONSUMER owns what smoke means, because that is repo knowledge. +name: ci-smoke + +on: + workflow_call: + inputs: + image: + description: Image reference to smoke test (digest-pinned) + required: true + type: string + secrets: + APP_ID: + description: GitHub App id. Only needed to pull from another namespace. + required: false + APP_PRIVATE_KEY: + description: Private key for the above App + required: false + +permissions: + contents: read + packages: read + +jobs: + smoke: + name: smoke test image + runs-on: ubuntu-latest + # A CEILING, not an estimate. Without one the job inherits GitHub's + # 360-minute default, and this is the one stage that runs code the SHARED + # repo did not write — `task smoke` belongs to the consumer. A smoke test + # waiting forever on a container that never becomes ready is exactly the + # shape that needs a ceiling; 30 is deliberately generous because what it + # does is repo knowledge. + timeout-minutes: 30 + permissions: + contents: read + packages: read + env: + APP_ID: ${{ secrets.APP_ID }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Generate a GitHub App token + id: app-token + if: ${{ env.APP_ID != '' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ steps.app-token.outputs.token && 'x-access-token' || github.actor }} + password: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Set up Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run task smoke + env: + IMAGE: ${{ inputs.image }} + run: task smoke diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..78b418b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,181 @@ +# Orchestrator — the ONLY file here with an `on:` block. Every stage is a LOCAL +# reusable workflow sitting beside this one; nothing below is implementation. +# +# The stages are files this repo owns, written here by the template, so a run +# can never be changed by anything outside this commit. `copier update` is what +# moves them forward. +# +# A local `uses: ./…` pointing at a missing COMPOSITE ACTION is silent to +# actionlint (a missing reusable workflow it does catch), so ci-lint.yml +# runs scripts/local-refs.sh to catch a stage this repo does not ship. That gate +# strips a leading `#` before matching, so DO NOT disable a job by commenting it +# out — a commented reference to an absent stage still fails. Delete it instead. +# +# REQUIRES APP SECRETS. GITHUB_TOKEN cannot trigger workflows, so without +# APP_ID/APP_PRIVATE_KEY release-please's own release PR gets no run, `ci` never +# reports, and the PR is unmergeable forever with no error explaining why. +name: CI + +# push only, no pull_request — branch-push checks surface on the PR anyway. +# KNOWN GAP: fork PRs get no CI at all. +on: + push: + branches: ['**'] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + lint: + uses: ./.github/workflows/ci-lint.yml + + # A SEPARATE STAGE, deliberately. `services:` is a job-level key, so neither a + # composite action nor a called workflow can declare one on the caller's + # behalf, and the caller cannot inject it — which is why this cannot fold into + # ci-lint.yml. It joins the `ci` gate below like any other stage. + integration: + uses: ./.github/workflows/ci-integration.yml + + # KNOWN COVERAGE GAP, RECORDED HERE ON PURPOSE — there is no e2e stage. + # + # The pull-request-workflow.yaml this pipeline replaces ran a third tier: a + # kind cluster via `make run-e2e`, exercising internal/it behind the `e2e` + # build tag. The template models two tiers (unit in ci-lint, envtest in + # ci-integration) and has no equivalent, so swapping pipelines drops that tier. + # + # NOT restored here, deliberately. internal/it carries a known race — the + # wstunnel-sidecar spec waits on Deployment READINESS, which the pre-patch + # Deployment already satisfies, so it can assert before the controller has + # rebuilt the pod spec. Wiring an intermittently-red suite into `ci`, the single + # required check, would leave main randomly unmergeable. The correct fix is to + # wait on status.observedGeneration catching up to metadata.generation; issue #2 + # narrows the window but does not close it. + # + # `make run-e2e` still works locally and is the way to run this tier today. + # Restoring it as a stage is follow-up work that depends on fixing that wait + # first — a flaky required gate is worse than an honest gap. + + # Always multi-arch, always pushed. Outputs a DIGEST-pinned ref. + # + # No App secrets: passing them here does not merely do nothing, it FAILS — + # ci-build logs in with the App token whenever APP_ID is non-empty, and a + # typical App installation has no package-write grant. + # + # `image:` IS EMITTED HERE AND ON EVERY OTHER STAGE THAT TAKES ONE — see the + # note on release-image below. The condition is `image_name` being non-empty, + # not a comparison against a rendered `ghcr.io//` literal: the + # owner is not knowable at render time, and duplicating a guess at it here was + # the second copy of a literal that belonged in neither file. + build: + needs: [lint] + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-build.yml + with: + image: ghcr.io/jacaudi/wireguard-operator/manager + # Both images build from the REPO ROOT — they COPY go.mod, cmd, api and + # internal — so the context is `.` for both and only the Dockerfile path + # differs. See ci-build.yml's `file:` input for why that input exists here + # and not upstream. + file: images/manager/Dockerfile + + # THE SECOND PUBLISHED IMAGE. The template models exactly one, and its + # `extra_build_contexts` are compile-check-only — they route to + # ci-build-check.yml, which declares no outputs and never pushes. The agent + # must actually be published: the manager takes it as a runtime argument + # (`--agent-image=` in config/default/manager_args_patch.yaml), so an + # unpublished agent means the operator points at an image that does not exist. + # + # A SEPARATE JOB, NOT A MATRIX LEG. ci-build.yml declares `outputs.image`, and + # a matrixed job's outputs are last-leg-wins with no guaranteed ordering — so + # matrixing it would hand `smoke` whichever digest finished last. Two jobs each + # own their own outputs. + build-agent: + needs: [lint] + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-build.yml + with: + image: ghcr.io/jacaudi/wireguard-operator/agent + file: images/agent/Dockerfile + + # Boot the built image and assert it behaves. What "behaves" means is repo + # knowledge, so it lives in `task smoke` — the one place CI still calls the + # taskfile. + # + # MANAGER ONLY, and that is a known gap rather than an oversight: `task smoke` + # asserts the manager binary runs and exposes `-agent-image`. The agent's + # equivalent needs its own assertion, which is a separate piece of repo + # knowledge and a separate change. + smoke: + needs: [build] + permissions: + contents: read + packages: read + uses: ./.github/workflows/ci-smoke.yml + with: + image: ${{ needs.build.outputs.image }} + + # NO `release` / `release-image` JOBS YET — DELIBERATELY OMITTED, NOT LOST. + # + # ci-release.yml declares APP_ID and APP_PRIVATE_KEY as REQUIRED secrets with + # no GITHUB_TOKEN fallback, and this repo has neither: `gh api + # repos/jacaudi/wireguard-operator/actions/secrets` returns total_count 0, with + # no environments and no org (the owner is a personal account). Wiring the jobs + # anyway would open a release PR that never gets a run, never reports `ci`, and + # is unmergeable forever — the exact failure ci-release.yml's own comments + # describe. + # + # The stage files, .github/release-please-config.json and the manifest seeded at + # 2.11.0 are all committed, so restoring these two jobs is purely additive: set + # the secrets with `hopsctl github secrets --repo jacaudi/wireguard-operator`, + # then re-add `release` (needs: [smoke], main-only, calling ci-release.yml) and + # `release-image` (needs: [build, release], calling ci-retag.yml with + # image: ghcr.io/jacaudi/wireguard-operator/manager). + # + # THE AGENT WILL NEED A RETAG JOB OF ITS OWN at that point. ci-retag.yml takes a + # single `source`/`image` pair, so the second published image needs a second + # call — omitting it does not fail, it silently leaves the agent without its + # v1.2.3 / v1.2 / v1 / latest tags while the manager gets them. + # + # release-republish.yml is likewise dormant until then: it is workflow_dispatch + # only, so it costs nothing sitting here, and it repairs the manager only. + + # THE single required status check — point every ruleset at `ci` and nothing + # else. A REGULAR job, never a called workflow: a job that CALLS a reusable + # workflow reports as ` / `, which no single-context ruleset + # can require. + ci: + # build-agent IS IN THIS LIST, and it has to be. A stage absent from `needs` + # can fail without failing `ci`, so a broken agent image would merge green — + # the same fail-open shape as a required check that is merely skipped. + needs: [lint, integration, build, build-agent, smoke] + # MANDATORY. Without it this job is SKIPPED when an upstream fails, and + # GitHub accepts a skipped required check as SATISFIED. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Assert required stages + env: + LINT: ${{ needs.lint.result }} + INTEGRATION: ${{ needs.integration.result }} + BUILD: ${{ needs.build.result }} + BUILD_AGENT: ${{ needs.build-agent.result }} + SMOKE: ${{ needs.smoke.result }} + run: | + set -euo pipefail + fail=0 + for stage in lint:"${LINT}" integration:"${INTEGRATION}" build:"${BUILD}" build-agent:"${BUILD_AGENT}" smoke:"${SMOKE}"; do + name="${stage%%:*}"; result="${stage##*:}" + printf '%-12s %s (must be success)\n' "${name}" "${result}" + [ "${result}" = "success" ] || fail=1 + done + [ "${fail}" = "0" ] || { echo "::error::one or more required stages did not pass"; exit 1; } diff --git a/.github/workflows/manual-dev-release-workflow.yaml b/.github/workflows/manual-dev-release-workflow.yaml deleted file mode 100644 index f705361..0000000 --- a/.github/workflows/manual-dev-release-workflow.yaml +++ /dev/null @@ -1,70 +0,0 @@ -name: Manual DEV Release -on: - workflow_dispatch: - inputs: - repository: - description: 'Repository name with owner.' - required: true - default: 'nccloud/wireguard-operator' - type: string - branch: - description: 'The branch, tag or SHA to checkout.' - required: true - default: 'main' - type: string - platforms: - description: 'Docker image platforms' - required: true - default: 'linux/amd64, linux/arm64' - type: string - tag: - description: 'Docker image tag' - required: true - default: 'feature-1' - type: string - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -permissions: - contents: read - packages: write - -jobs: - build-images: - uses: ./.github/workflows/build-images.yaml - with: - push: true - latest: false - ref: ${{ inputs.branch }} - repository: ${{ inputs.repository }} - tag: dev-${{ inputs.tag }} - - save-release: - needs: [build-images] - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - repository: ${{ inputs.repository }} - ref: ${{ inputs.branch }} - submodules: true - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - name: prepare new release - env: - MANAGER_IMAGE: ghcr.io/${{ inputs.repository }}/manager:dev-${{ inputs.tag }} - AGENT_IMAGE: ghcr.io/${{ inputs.repository }}/agent:dev-${{ inputs.tag }} - run: | - make generate-release-file AGENT_IMAGE="$AGENT_IMAGE" MANAGER_IMAGE="$MANAGER_IMAGE" - - name: upload release - uses: actions/upload-artifact@v7 - with: - name: release.yaml - path: ${{ github.workspace }}/release.yaml diff --git a/.github/workflows/pull-request-workflow.yaml b/.github/workflows/pull-request-workflow.yaml deleted file mode 100644 index d1890e4..0000000 --- a/.github/workflows/pull-request-workflow.yaml +++ /dev/null @@ -1,86 +0,0 @@ -name: PR pipeline -on: - pull_request: - paths: - - "**.go" - - "go.mod" - - "go.sum" - - "Makefile" - - "images/**" - - "config/**" - - ".github/workflows/pull-request-workflow.yaml" - -permissions: - contents: read - checks: write - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - # REQUIRED BY THE RATCHET, and its absence fails OPEN rather than loud. - # .golangci.yml sets `new-from-merge-base: origin/main`, which needs the - # merge base to exist locally. At the default depth of 1 it does not, - # and golangci-lint downgrades that to a warning — - # "Can't process results by diff processor: ... could not read git repo" - # — then reports the FULL tree: 113 pre-existing findings, and a red - # gate that says nothing about the change under review. - fetch-depth: 0 - - - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: latest - - test: - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - name: Run tests - run: make test-ci - - - name: Test report - uses: dorny/test-reporter@v3 - if: always() - with: - name: Unit tests - path: test-report.json - reporter: golang-json - - e2e: - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v6 - with: - submodules: true - - - name: Build images - env: - MANAGER_IMAGE: wireguard-operator/manager:test - AGENT_IMAGE: wireguard-operator/agent:test - run: | - make docker-build-integration-test - - - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - uses: azure/setup-kubectl@v5 - - - name: Run e2e tests - run: | - make kind - make run-e2e AGENT_IMAGE=wireguard-operator/agent:test MANAGER_IMAGE=wireguard-operator/manager:test diff --git a/.github/workflows/release-republish.yml b/.github/workflows/release-republish.yml new file mode 100644 index 0000000..9aee0be --- /dev/null +++ b/.github/workflows/release-republish.yml @@ -0,0 +1,158 @@ +# Manual repair of a release image — retag (registry-side copy, preferred) or rebuild. +# +# THIS FILE IS RENDERED, and it had better be. It carried no Jinja at all and was +# byte-identical across every service render, which meant it hardcoded +# `ghcr.io/${{ github.repository }}` and passed no `context:` — so the one manual +# repair path was wrong for exactly the repos most likely to need it: the ones +# whose image or build context is not the default. It was found during an +# incident, which is the worst possible moment to discover that the recovery tool +# is pointed at the wrong repository. +# +# NO APP SECRETS TO ci-build / ci-smoke. It used to pass APP_ID and +# APP_PRIVATE_KEY to both, which the README warns in bold against and ci.yaml has +# never done: ci-build logs in with the App token whenever APP_ID is non-empty, +# and a typical App installation has no package-write grant, so passing it there +# does not merely do nothing — it FAILS with `denied: permission_denied`. The +# README rule was right and this file was wrong. GITHUB_TOKEN reaches every +# package under the repo's own owner that this repo created, which is what both +# the ordinary pipeline and this repair path push to. +name: release-republish + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag to repair (e.g. v1.2.3) + required: true + type: string + mode: + description: retag the CI-built image (preferred), or rebuild from the tag + required: true + default: retag + type: choice + options: + - retag + - rebuild + +concurrency: + group: release-republish-${{ inputs.tag }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + retag: + name: retag ${{ inputs.tag }} + if: ${{ inputs.mode == 'retag' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.out.outputs.image }} + env: + APP_ID: ${{ secrets.APP_ID }} + # The SAME name ci.yaml gives ci-build and ci-retag. Hardcoded, this job + # repaired a repository the pipeline never publishes to. + IMAGE: ghcr.io/jacaudi/wireguard-operator/manager + steps: + - name: Resolve the tag to its commit + id: commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" --jq '.object.sha') + type=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" --jq '.object.type') + if [ "${type}" = "tag" ]; then + sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha') + fi + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + echo "short=sha-${sha:0:7}" >> "$GITHUB_OUTPUT" + echo "${TAG} -> ${sha} (${sha:0:7})" + + - name: Generate a GitHub App token + id: app-token + if: ${{ env.APP_ID != '' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ steps.app-token.outputs.token && 'x-access-token' || github.actor }} + password: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + - name: Verify the source image was built from the tagged commit + env: + SRC: ${{ env.IMAGE }}:${{ steps.commit.outputs.short }} + WANT: ${{ steps.commit.outputs.sha }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + echo "source image: ${SRC}" + docker pull -q "${SRC}" + have=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "${SRC}") + echo "tag commit: ${WANT}" + echo "image revision: ${have}" + [ "${WANT}" = "${have}" ] || { echo "::error::${SRC} was built from ${have}, refusing to retag it as ${TAG}"; exit 1; } + + - name: Point the semver tags at that image + id: out + env: + SRC: ${{ env.IMAGE }}:${{ steps.commit.outputs.short }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + ver="${TAG#v}" + # Refuse prereleases: metadata-action never produced :v1 / :v1.2 for + # them, so moving those tags here would clobber the last stable release. + case "${ver}" in + *-*|*+*) echo "::error::${TAG} is a prerelease; ci-build publishes no moving tags for it"; exit 1 ;; + esac + case "${ver}" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) echo "::error::${TAG} is not vMAJOR.MINOR.PATCH"; exit 1 ;; + esac + major="${ver%%.*}" + minor="${ver%.*}" + docker buildx imagetools create \ + -t "${IMAGE}:v${ver}" \ + -t "${IMAGE}:v${minor}" \ + -t "${IMAGE}:v${major}" \ + "${SRC}" + digest=$(docker buildx imagetools inspect "${IMAGE}:v${ver}" --format '{{json .Manifest.Digest}}' | tr -d '"') + echo "image=${IMAGE}@${digest}" >> "$GITHUB_OUTPUT" + echo "v${ver}, v${minor}, v${major} -> ${digest}" + + rebuild: + name: rebuild ${{ inputs.tag }} + if: ${{ inputs.mode == 'rebuild' }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-build.yml + with: + version: ${{ inputs.tag }} + ref: ${{ inputs.tag }} + image: ghcr.io/jacaudi/wireguard-operator/manager + # REQUIRED HERE TOO. Neither image sits at its build context root, so + # without this the rebuild path fails with "no Dockerfile or Containerfile + # in ." — and it would fail during an incident, which is the worst moment + # to find out the repair tool never worked. See ci-build.yml's `file:` input. + file: images/manager/Dockerfile + + smoke: + needs: [retag, rebuild] + if: ${{ !cancelled() && (needs.retag.result == 'success' || needs.rebuild.result == 'success') }} + permissions: + contents: read + packages: read + uses: ./.github/workflows/ci-smoke.yml + with: + image: ${{ needs.retag.outputs.image || needs.rebuild.outputs.image }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 33f7d8a..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Release -on: - release: - types: - - published - -permissions: write-all - -jobs: - build-images: - uses: ./.github/workflows/build-images.yaml - with: - latest: true - push: true - tag: ${{ github.ref_name }} - ref: ${{ github.sha }} diff --git a/.gitignore b/.gitignore index 5e15bd5..bd9a768 100644 --- a/.gitignore +++ b/.gitignore @@ -94,8 +94,15 @@ charts/*.tgz # kubebuilder test assets (predates the managed block) testbin/* -# Generated release manifest and release-it config. ANCHORED with a leading -# slash: an unanchored `release.yaml` matches at any depth and silently -# swallowed .github/workflows/release.yaml, which must stay tracked. -/release.yaml +# release_it.yaml is the throwaway manifest `make run-e2e` renders against a +# kind cluster. ANCHORED with a leading slash: an unanchored pattern matches at +# any depth, which is how an earlier `release.yaml` entry silently swallowed +# .github/workflows/release.yaml. +# +# release.yaml IS DELIBERATELY NOT LISTED HERE. It is generated, but it is also +# committed and is this repo's documented install path, so ci-lint.yml gates it +# for drift against config/. It was previously both tracked AND ignored — inert, +# because tracking wins, but a trap: the gate's own remediation is "regenerate +# and commit", and a plain `git add release.yaml` against an ignored path is a +# silent no-op. /release_it.yaml diff --git a/hack/release-file-drift.sh b/hack/release-file-drift.sh new file mode 100755 index 0000000..db68a12 --- /dev/null +++ b/hack/release-file-drift.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Assert the committed release.yaml still matches what config/ renders to. +# +# release.yaml is this repo's documented install path — users apply it straight +# from a tag — and it is a GENERATED file: `make generate-release-file` renders +# it with kustomize from config/default. So it can silently fall behind the CRDs, +# RBAC and Deployment it is supposed to ship, and nothing would say so until +# someone installed a stale manifest. +# +# This regenerates and FAILS on any diff. It never fixes the tree: a CI step that +# mutates what it is checking hides the problem it exists to surface. Same +# contract as the codegen gate in ci-lint.yml, and as scripts/chart-drift.sh for +# repos that ship a chart. +# +# WHY THE IMAGE PINS ARE READ BACK OUT OF THE FILE rather than passed in: +# generate-release-file bakes an image:tag into the output, and its Makefile +# defaults are the local dev values (agent:dev / manager:dev). Regenerating with +# those would report drift on every single run. The committed pins are also +# release-scoped — they name the version last released, which a push to a branch +# has no way to know and must not "correct". So this gate checks the STRUCTURE +# (CRDs, RBAC, Deployment shape) against config/, holding the pins fixed at +# whatever is committed. Bumping them is release-time work, not gate work. +set -eu + +cd "$(git rev-parse --show-toplevel)" + +if [ ! -f release.yaml ]; then + echo "::error::release.yaml is missing — it is the documented install path and must be committed" + exit 1 +fi + +# `- --agent-image=` inside the manager container's args. +agent=$(sed -n 's/^[[:space:]]*- --agent-image=//p' release.yaml | head -1) +# `image: ` at exactly eight spaces — the manager container. The other two +# `image:` matches in this file are CRD schema PROPERTY NAMES with no value on +# the line, so they cannot collide. +manager=$(sed -n 's/^ image: //p' release.yaml | head -1) + +if [ -z "${agent}" ] || [ -z "${manager}" ]; then + echo "::error::could not read the agent/manager image pins out of release.yaml" + echo "::error::agent='${agent}' manager='${manager}'" + echo "::error::if the manifest layout changed, this script's patterns need updating" + exit 1 +fi + +echo "regenerating release.yaml with the committed pins:" +echo " agent: ${agent}" +echo " manager: ${manager}" + +make generate-release-file AGENT_IMAGE="${agent}" MANAGER_IMAGE="${manager}" >/dev/null + +if ! git diff --exit-code -- release.yaml; then + echo "::error::release.yaml is out of date with config/" + echo "::error::run: make generate-release-file AGENT_IMAGE=${agent} MANAGER_IMAGE=${manager}" + echo "::error::then commit the result" + exit 1 +fi + +echo "release.yaml matches config/" From cc727021704bd9382072c711b74e5315d8aa17a7 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:30:31 -0700 Subject: [PATCH 2/6] build: retarget every image reference at ghcr.io/jacaudi/wireguard-operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fork inherited the upstream nccloud image names, which it cannot publish to. The registry half of #36, landed here because it is an input to the copier answers the workflow commit records — the pipeline and the manifests have to name the same repository or the operator points at an image that is never built. Both images move together, and they have to: the manager takes the agent image as a runtime argument, so renaming one without the other produces a deployment that pulls something that does not exist. Makefile IMAGE_TAG_BASE, which also derives BUNDLE_IMG config/manager/kustomization.yaml the manager's newName config/default/manager_args_patch.yaml the --agent-image default bundle/manifests/...clusterserviceversion.yaml release.yaml regenerated, not hand-edited release.yaml is regenerated with `make generate-release-file` at the pins it already carried (v2.11.0), so the diff is the namespace and nothing else. hack/release-file-drift.sh now proves that on every push. The module path is deliberately NOT touched — github.com/nccloud/wireguard-operator stays until #36's other half, which rewrites the import block in all 18 Go files and wants a quiet tree. README.md still points at the upstream Helm chart; this fork publishes none, so that is a docs fix rather than part of the rename. --- Makefile | 2 +- .../manifests/wireguard-operator.clusterserviceversion.yaml | 2 +- config/default/manager_args_patch.yaml | 2 +- config/manager/kustomization.yaml | 2 +- release.yaml | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index bd121a5..f5691ee 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) # # For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both # wireguard-operator.io/manager-bundle:$VERSION and wireguard-operator.io/manager-catalog:$VERSION. -IMAGE_TAG_BASE ?= ghcr.io/nccloud/wireguard-operator +IMAGE_TAG_BASE ?= ghcr.io/jacaudi/wireguard-operator # BUNDLE_IMG defines the image:tag used for the bundle. # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) diff --git a/bundle/manifests/wireguard-operator.clusterserviceversion.yaml b/bundle/manifests/wireguard-operator.clusterserviceversion.yaml index e74e39a..9ca8dd7 100644 --- a/bundle/manifests/wireguard-operator.clusterserviceversion.yaml +++ b/bundle/manifests/wireguard-operator.clusterserviceversion.yaml @@ -208,7 +208,7 @@ spec: - --leader-elect command: - /manager - image: ghcr.io/nccloud/wireguard-operator-operator:main + image: ghcr.io/jacaudi/wireguard-operator-operator:main livenessProbe: httpGet: path: /healthz diff --git a/config/default/manager_args_patch.yaml b/config/default/manager_args_patch.yaml index d1e7b81..439ae55 100644 --- a/config/default/manager_args_patch.yaml +++ b/config/default/manager_args_patch.yaml @@ -12,6 +12,6 @@ spec: - "--health-probe-bind-address=:8081" - "--metrics-bind-address=:8080" - "--leader-elect" - - "--agent-image=ghcr.io/nccloud/wireguard-operator/agent:latest" + - "--agent-image=ghcr.io/jacaudi/wireguard-operator/agent:latest" diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 2ef40a0..e11041d 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -8,5 +8,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: ghcr.io/nccloud/wireguard-operator/manager + newName: ghcr.io/jacaudi/wireguard-operator/manager newTag: latest diff --git a/release.yaml b/release.yaml index e87d271..2e47c93 100644 --- a/release.yaml +++ b/release.yaml @@ -967,7 +967,7 @@ spec: - --health-probe-bind-address=:8081 - --metrics-bind-address=:8080 - --leader-elect - - --agent-image=ghcr.io/nccloud/wireguard-operator/agent:v2.11.0 + - --agent-image=ghcr.io/jacaudi/wireguard-operator/agent:v2.11.0 command: - /manager env: @@ -975,7 +975,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - image: ghcr.io/nccloud/wireguard-operator/manager:v2.11.0 + image: ghcr.io/jacaudi/wireguard-operator/manager:v2.11.0 imagePullPolicy: IfNotPresent livenessProbe: httpGet: From 25ba292844d54f1db085ee4dddd00f220c51a724 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:30:48 -0700 Subject: [PATCH 3/6] fix: stop the Makefile reinstalling tools and eating uncommitted config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two latent Makefile bugs, both found by running the new release.yaml drift gate rather than by reading, and both of which the gate makes far more likely to fire because ci-lint.yml now invokes these targets on every push. TOOL INSTALLS RE-RAN AND FAILED. The four tool rules took $(LOCALBIN) as a normal prerequisite, so installing any one tool updated bin/'s mtime and made every other tool look stale. Make then re-ran its installer, and kustomize's refuses outright: .../bin/kustomize exists. Remove it first. make: *** [.../bin/kustomize] Error 1 ci-lint.yml reproduces the triggering order exactly — `make manifests generate` installs controller-gen, then the drift gate calls generate-release-file, which needs kustomize. A fresh runner happens to survive it by ordering luck, since each tool is installed before the next touches the directory; any cached bin/ or any second invocation does not. Order-only prerequisites (`| $(LOCALBIN)`) say "ensure the directory exists" without comparing timestamps against it. `git checkout` SILENTLY DISCARDED UNCOMMITTED WORK. generate-release-file and run-e2e both stamp release-time image pins into two config/ files, build, then restore them with `git checkout ./config/...`. That restores from the INDEX, so it does not put back what was there — it throws away any uncommitted edit to those files. It ate an in-progress image-rename edit while this branch was being written, with no output, and the only reason it was noticed is that the drift gate then reported release.yaml and config/ disagreeing. Replaced with an explicit save/restore around the mutation, in a trap so it also runs when the build fails. One shared PINNED_AT_BUILD list rather than the same two paths written out at four call sites. --- Makefile | 50 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index f5691ee..d486824 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,16 @@ LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): mkdir -p $(LOCALBIN) +# Every tool rule below takes $(LOCALBIN) as an ORDER-ONLY prerequisite (the +# `|`), and that pipe is load-bearing. As a normal prerequisite, installing any +# one tool updates bin/'s mtime, which makes every OTHER tool look stale — make +# re-runs its installer, and kustomize's refuses outright: +# .../bin/kustomize exists. Remove it first. +# ci-lint.yml reproduces that order exactly: `make manifests generate` installs +# controller-gen, then the release.yaml drift gate calls generate-release-file, +# which needs kustomize. Order-only means "ensure the directory exists" without +# comparing timestamps against it. + ## Tool Binaries KUSTOMIZE ?= $(LOCALBIN)/kustomize CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen @@ -157,11 +167,11 @@ docker-build-integration-test: docker-build-manager run-e2e: $(KIND) - AGENT_IMAGE=${AGENT_IMAGE} $(MAKE) update-agent-image - MANAGER_IMAGE=${MANAGER_IMAGE} $(MAKE) update-manager-image - $(KUSTOMIZE) build config/default > release_it.yaml - git checkout ./config/default/manager_args_patch.yaml - git checkout ./config/manager/kustomization.yaml + @for f in $(PINNED_AT_BUILD); do cp "$$f" "$$f.pre-build"; done + @trap 'for f in $(PINNED_AT_BUILD); do mv "$$f.pre-build" "$$f"; done' EXIT; \ + AGENT_IMAGE=${AGENT_IMAGE} $(MAKE) update-agent-image && \ + MANAGER_IMAGE=${MANAGER_IMAGE} $(MAKE) update-manager-image && \ + $(KUSTOMIZE) build config/default > release_it.yaml KUBECONFIG=$(HOME)/.kube/config KUBE_CONFIG=$(HOME)/.kube/config KIND_BIN=${KIND} WIREGUARD_OPERATOR_RELEASE_PATH="../../release_it.yaml" AGENT_IMAGE=${AGENT_IMAGE} MANAGER_IMAGE=${MANAGER_IMAGE} SKIP_CLEANUP=${SKIP_CLEANUP} go test -tags=e2e ./internal/it/ -v -count=1 docker-push: ## Push docker image with the manager. @@ -175,6 +185,19 @@ install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. $(KUSTOMIZE) build config/crd | kubectl delete -f - +# These two files are mutated in place by update-agent-image and +# update-manager-image so a build can stamp release-time image pins into them, +# then restored — they are not meant to carry those pins in the tree. +# +# RESTORED FROM A SAVED COPY, NOT `git checkout`. Both targets below used to run +# `git checkout ./config/default/manager_args_patch.yaml`, which restores from the +# INDEX and therefore silently discards any uncommitted edit to these files. +# ci-lint.yml now runs generate-release-file on every push via +# hack/release-file-drift.sh, so a developer reproducing the gate locally would +# lose in-progress work with no warning. Verified the hard way: it ate an +# image-rename edit exactly that way while this branch was being written. +PINNED_AT_BUILD := config/default/manager_args_patch.yaml config/manager/kustomization.yaml + update-agent-image: kustomize sed 's|$${AGENT_IMAGE}|$(AGENT_IMAGE)|g' ./config/default/manager_args_patch.yaml.template > ./config/default/manager_args_patch.yaml @@ -182,10 +205,11 @@ update-manager-image: kustomize $(info MANAGER_IMAGE: "$(MANAGER_IMAGE)") cd config/manager && $(KUSTOMIZE) edit set image controller=${MANAGER_IMAGE} -generate-release-file: kustomize update-agent-image update-manager-image - $(KUSTOMIZE) build config/default > release.yaml - git checkout ./config/default/manager_args_patch.yaml - git checkout ./config/manager/kustomization.yaml +generate-release-file: kustomize + @for f in $(PINNED_AT_BUILD); do cp "$$f" "$$f.pre-build"; done + @trap 'for f in $(PINNED_AT_BUILD); do mv "$$f.pre-build" "$$f"; done' EXIT; \ + $(MAKE) update-agent-image update-manager-image && \ + $(KUSTOMIZE) build config/default > release.yaml deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} @@ -196,20 +220,20 @@ undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/confi .PHONY: controller-gen controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. -$(CONTROLLER_GEN): $(LOCALBIN) +$(CONTROLLER_GEN): | $(LOCALBIN) GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) kind: $(KIND) ## Download kind locally if necessary. -$(KIND): $(LOCALBIN) +$(KIND): | $(LOCALBIN) GOBIN=$(LOCALBIN) go install sigs.k8s.io/kind/cmd/kind@$(KIND_VERSION) KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. -$(KUSTOMIZE): $(LOCALBIN) +$(KUSTOMIZE): | $(LOCALBIN) curl -s $(KUSTOMIZE_INSTALL_SCRIPT) | bash -s -- $(subst v,,$(KUSTOMIZE_VERSION)) $(LOCALBIN) .PHONY: envtest envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. -$(ENVTEST): $(LOCALBIN) +$(ENVTEST): | $(LOCALBIN) GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest From f2e9e25af60f088ef677aba798d12d36db32dd97 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:30:59 -0700 Subject: [PATCH 4/6] build: pin the Go toolchain so govulncheck stops depending on the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go.mod said `go 1.26`, so actions/setup-go installed whatever patch the runner image happened to ship. That made govulncheck's STDLIB findings a property of the runner rather than of this repo: 7 findings on go1.26.4, zero on go1.26.6, with CI observed on go1.26.7. A gate whose verdict depends on the day it ran is not a gate — the same fail-quietly shape as the lint ratchet at fetch-depth 1 and a build tag no file carries, both of which bit this repo on #42. Pinned via `toolchain` rather than by raising the `go` directive, so the module's minimum requirement stays 1.26 for anyone importing it while the compiler CI actually uses is fixed. Renovate bumps it like any other dependency. Left open as an acceptance criterion on #40; closing it here because the new pipeline runs govulncheck as a gating step, which is the point at which non-determinism stops being cosmetic. --- go.mod | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/go.mod b/go.mod index cd4e984..c4516ff 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,17 @@ module github.com/nccloud/wireguard-operator go 1.26 +// PINNED DELIBERATELY, and via `toolchain` rather than by raising the `go` +// directive above — that keeps this module's minimum requirement at 1.26 for +// anyone importing it, while fixing the compiler CI actually uses. +// +// `go 1.26` on its own lets actions/setup-go install whatever patch the runner +// image happens to ship, which made govulncheck's STDLIB findings a property of +// the runner rather than of this repo: 7 findings on go1.26.4, zero on go1.26.6, +// with CI observed on go1.26.7. A gate whose verdict depends on the day it ran +// is not a gate. Renovate bumps this like any other dependency. +toolchain go1.26.7 + require ( github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.3 From 5cab55f7d4bb86ef7141641f2d216101723f9d66 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:37:40 -0700 Subject: [PATCH 5/6] ci: wire release-please and both image retags into the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release jobs were held back earlier in this branch only because APP_ID and APP_PRIVATE_KEY were absent — ci-release.yml requires both with no GITHUB_TOKEN fallback, and wiring it without them opens a release PR that never gets a run, never reports `ci`, and is unmergeable forever. The secrets are now set (verified: actions/secrets reports both), so the reason is gone and the comment describing it would have shipped false. release main-only, calls ci-release.yml with both secrets release-image retags the manager digest that passed smoke release-image-agent the same for the agent THE AGENT'S RETAG IS A SEPARATE JOB BECAUSE ci-retag.yml TAKES ONE IMAGE. Its absence would not have failed anything: the agent would have kept the :sha and :latest tags build-agent gives it and silently never received v2.11.1, v2.11 or v2, while the manager did. The two images would then drift apart at exactly the tags a versioned deploy uses — and the manager's --agent-image default names a versioned agent tag, so that is a broken deployment rather than a cosmetic gap. Retag rather than rebuild, for both: release-please tags the release PR's merge commit, which is the commit this run already built, so retagging publishes the same bytes that passed smoke. A rebuild yields a new digest nothing has tested. release-republish.yml still repairs the manager only — both its paths take one hardcoded image and `task smoke` only knows the manager's shape. Recorded in the file with the two-line imagetools incantation for repairing the agent by hand, rather than left to be discovered during an incident. --- .github/workflows/ci.yaml | 80 ++++++++++++++++++------- .github/workflows/release-republish.yml | 10 ++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 78b418b..08d8b6e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -123,30 +123,68 @@ jobs: with: image: ${{ needs.build.outputs.image }} - # NO `release` / `release-image` JOBS YET — DELIBERATELY OMITTED, NOT LOST. + # main only. An ordinary main push opens/updates the release PR; merging that + # PR creates the tag and the GitHub Release. # - # ci-release.yml declares APP_ID and APP_PRIVATE_KEY as REQUIRED secrets with - # no GITHUB_TOKEN fallback, and this repo has neither: `gh api - # repos/jacaudi/wireguard-operator/actions/secrets` returns total_count 0, with - # no environments and no org (the owner is a personal account). Wiring the jobs - # anyway would open a release PR that never gets a run, never reports `ci`, and - # is unmergeable forever — the exact failure ci-release.yml's own comments - # describe. + # ci-release.yml declares APP_ID and APP_PRIVATE_KEY as REQUIRED, with no + # GITHUB_TOKEN fallback — deliberately, and the one thing here never to + # "improve". GITHUB_TOKEN cannot trigger workflows, so a silent fallback opens a + # release PR that never gets a run, never reports `ci`, and is unmergeable + # forever with nothing explaining why. Both secrets are set on this repo + # (verified: actions/secrets reports APP_ID and APP_PRIVATE_KEY). # - # The stage files, .github/release-please-config.json and the manifest seeded at - # 2.11.0 are all committed, so restoring these two jobs is purely additive: set - # the secrets with `hopsctl github secrets --repo jacaudi/wireguard-operator`, - # then re-add `release` (needs: [smoke], main-only, calling ci-release.yml) and - # `release-image` (needs: [build, release], calling ci-retag.yml with - # image: ghcr.io/jacaudi/wireguard-operator/manager). - # - # THE AGENT WILL NEED A RETAG JOB OF ITS OWN at that point. ci-retag.yml takes a - # single `source`/`image` pair, so the second published image needs a second - # call — omitting it does not fail, it silently leaves the agent without its - # v1.2.3 / v1.2 / v1 / latest tags while the manager gets them. + # The manifest is seeded at 2.11.0 — the version this fork's tree descends + # from — via .github/release-please-manifest.json and never via `release-as`, + # which is a permanent override rather than a bootstrap. + release: + needs: [smoke] + if: ${{ github.ref == 'refs/heads/main' }} + permissions: + contents: write + pull-requests: write + uses: ./.github/workflows/ci-release.yml + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + + # RETAG, not rebuild. release-please tags the release PR's merge commit — the + # exact commit `build` just built — so the published image is the same digest + # that passed smoke, which a rebuild cannot guarantee. # - # release-republish.yml is likewise dormant until then: it is workflow_dispatch - # only, so it costs nothing sitting here, and it repairs the manager only. + # `image:` IS NOT OPTIONAL. Every stage taking this input falls back to + # ghcr.io// on its own, so omitting it is not silence — it is a + # DIFFERENT repository, and the final digest-confirm step would re-inspect the + # wrong one and compare it against itself. + release-image: + needs: [build, release] + if: ${{ needs.release.outputs.release_created == 'true' }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-retag.yml + with: + source: ${{ needs.build.outputs.image }} + version: ${{ needs.release.outputs.tag_name }} + image: ghcr.io/jacaudi/wireguard-operator/manager + + # THE AGENT NEEDS ITS OWN RETAG, and its absence would not fail anything. + # ci-retag.yml takes a single source/image pair, so the second published image + # needs a second call. Without this job the agent keeps only its :sha and + # :latest tags from `build-agent` and silently never receives v1.2.3, v1.2 or + # v1 — while the manager does, so the two images drift apart at exactly the + # tags anyone deploying by version would use. The manager's --agent-image + # default points at a versioned agent tag, so that gap is not cosmetic. + release-image-agent: + needs: [build-agent, release] + if: ${{ needs.release.outputs.release_created == 'true' }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-retag.yml + with: + source: ${{ needs.build-agent.outputs.image }} + version: ${{ needs.release.outputs.tag_name }} + image: ghcr.io/jacaudi/wireguard-operator/agent # THE single required status check — point every ruleset at `ci` and nothing # else. A REGULAR job, never a called workflow: a job that CALLS a reusable diff --git a/.github/workflows/release-republish.yml b/.github/workflows/release-republish.yml index 9aee0be..48eae09 100644 --- a/.github/workflows/release-republish.yml +++ b/.github/workflows/release-republish.yml @@ -16,6 +16,16 @@ # README rule was right and this file was wrong. GITHUB_TOKEN reaches every # package under the repo's own owner that this repo created, which is what both # the ordinary pipeline and this repair path push to. +# +# REPAIRS THE MANAGER ONLY — a known limit, not an oversight. This repo publishes +# two images, but both the retag and rebuild paths here take a single hardcoded +# IMAGE, and `task smoke` only knows how to assert the manager binary. Extending +# it would mean threading an image choice through all three jobs plus teaching +# smoke a second shape, which is real work with no incident asking for it yet. +# To repair the agent by hand: +# docker buildx imagetools create \ +# -t ghcr.io/jacaudi/wireguard-operator/agent:vX.Y.Z \ +# ghcr.io/jacaudi/wireguard-operator/agent:sha- name: release-republish on: From a0414d77700e3f0c265e04f613f4485f1e4623b8 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:37:48 -0700 Subject: [PATCH 6/6] chore: ignore the session handoff prompts directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/prompts/ holds scaffolding written for a fresh Claude Code session, not project artefacts. Committed on this branch rather than on main because this branch already rewrites the surrounding block of .gitignore — landing the same line in two places would resurrect the `/release.yaml` entry this branch deliberately removes. The equivalent uncommitted edit sitting in the main checkout is superseded by this and can be discarded with `git restore .gitignore`. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index bd9a768..7b9cd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,7 @@ testbin/* # and commit", and a plain `git add release.yaml` against an ignored path is a # silent no-op. /release_it.yaml + +# Session handoff prompts — scaffolding for a fresh Claude Code session, not +# project artefacts. +docs/prompts/