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..08d8b6e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,219 @@ +# 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 }} + + # 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, 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 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. + # + # `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 + # 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..48eae09 --- /dev/null +++ b/.github/workflows/release-republish.yml @@ -0,0 +1,168 @@ +# 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. +# +# 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: + 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..7b9cd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,8 +94,19 @@ 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 + +# Session handoff prompts — scaffolding for a fresh Claude Code session, not +# project artefacts. +docs/prompts/ diff --git a/Makefile b/Makefile index bd121a5..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 @@ -54,7 +64,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=/:) @@ -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 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/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 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/" 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: