# The whole pipeline: checks on every push, and on main/tags an image published to # GHCR. One file rather than one per check, because the gating relationship IS the # pipeline — `publish` names its prerequisites in `needs`, so a check can't go red # while an image ships. TestBuildDependsOnEveryGatingJob asserts that wiring. # # There is no deploy step: deployment lives in a separate infrastructure repository, # which resolves the published tag to a digest and rolls it out. What this workflow # owes that deployer is the digest itself — see the `publish` job's summary output, # and "Verifying a build" in README.md for why the digest (not a tag) is the thing # that gets deployed and reported by /version. # # There is no Dockerfile. MeshTender is a pure-Go binary with migrations, templates, # and static assets embedded via go:embed, so ko compiles it and lays it straight # onto the digest-pinned base image from .ko.yaml — no build context and no daemon. name: CI on: push: branches: ["**"] tags: ["v*"] workflow_dispatch: # A newer push to the same ref makes an in-flight run pointless. Tags are excluded # from cancellation by using the ref itself as the group: two different tags never # collide, so a release build always runs to completion. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true permissions: contents: read # Go's version is read from go.mod rather than written here: it is already pinned # exactly (TestReleasePinsAreConsistent enforces that it matches the mise pin), and # a second copy in this file is a second thing to forget. GOTOOLCHAIN=local then # forbids fetching any other toolchain, so a build can only ever use the pinned one # — the compiler version changes the binary, and the published image is meant to be # reproducible from a clean checkout. env: GOTOOLCHAIN: local jobs: lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod # Caches the module cache AND the build cache, keyed on go.sum, shared # across jobs and runs. Every job below compiles the same module, so this # is the difference between each one starting cold and starting warm. cache-dependency-path: go.sum # The action major has to match golangci-lint's major: v6 of the action only # drives golangci-lint v1, and .golangci.yml declares `version: "2"`. # TestLintVersionsAgree pins that relationship down. # # Both versions are pinned rather than "latest": a new release both invalidates # this action's analysis cache and can fail a build with no code change behind # it. Bump deliberately. (mise runs "latest" locally, where a surprise is cheap.) - uses: golangci/golangci-lint-action@v9.3.0 with: version: v2.13.1 test: runs-on: ubuntu-latest timeout-minutes: 20 services: # ONE Postgres for the whole run. internal/testdb clones a migrated template # database per test against whatever server this points at; without it, every # package binary starts its own container via testcontainers, and `go test # ./...` runs packages in parallel — which on a 4-vCPU runner meant ~8 Postgres # containers competing for CPU until connections started failing. postgres: image: postgres:17 env: POSTGRES_USER: meshtender POSTGRES_PASSWORD: meshtender POSTGRES_DB: meshtender_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U meshtender" --health-interval 5s --health-timeout 5s --health-retries 10 env: # The harness creates and drops only its own mt_tmpl_* / mt_test_* databases # on this server, so pointing at the "postgres" maintenance DB is safe. MESHTENDER_TEST_DATABASE_URL: postgres://meshtender:meshtender@localhost:5432/postgres?sslmode=disable steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod # Caches the module cache AND the build cache, keyed on go.sum, shared # across jobs and runs. Every job below compiles the same module, so this # is the difference between each one starting cold and starting warm. cache-dependency-path: go.sum # No `go vet` here: golangci-lint's standard set includes govet and runs it in # a parallel job, so vetting again would buy a second full analysis pass of the # module and nothing else. It stays in the local pre-push gate (CLAUDE.md). - run: go test -race ./... vuln: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod # Caches the module cache AND the build cache, keyed on go.sum, shared # across jobs and runs. Every job below compiles the same module, so this # is the difference between each one starting cold and starting warm. cache-dependency-path: go.sum # Pinned so the compiled tool stays in the build cache between runs; the vuln # database is fetched at run time, so this doesn't stale what it checks against. - run: go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 - run: govulncheck ./... # The licensing audit gates publishing for the same reason the vuln scan does: a # non-permissive dependency is a legal defect in the artifact, so it must not # reach the registry. licenses: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod # Caches the module cache AND the build cache, keyed on go.sum, shared # across jobs and runs. Every job below compiles the same module, so this # is the difference between each one starting cold and starting warm. cache-dependency-path: go.sum - run: go run ./cmd/licenses # Non-gating on purpose (it is absent from publish's `needs`): the browser suite # skips rather than fails when no browser is reachable, so requiring it would make # releases depend on an advisory check. e2e: runs-on: ubuntu-latest timeout-minutes: 20 services: # ONE Postgres for the whole run. internal/testdb clones a migrated template # database per test against whatever server this points at; without it, every # package binary starts its own container via testcontainers, and `go test # ./...` runs packages in parallel — which on a 4-vCPU runner meant ~8 Postgres # containers competing for CPU until connections started failing. postgres: image: postgres:17 env: POSTGRES_USER: meshtender POSTGRES_PASSWORD: meshtender POSTGRES_DB: meshtender_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U meshtender" --health-interval 5s --health-timeout 5s --health-retries 10 env: MESHTENDER_TEST_DATABASE_URL: postgres://meshtender:meshtender@localhost:5432/postgres?sslmode=disable steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod # Caches the module cache AND the build cache, keyed on go.sum, shared # across jobs and runs. Every job below compiles the same module, so this # is the difference between each one starting cold and starting warm. cache-dependency-path: go.sum # The browser container is started here rather than as a `services:` entry # because it needs a custom command (socat fronts the debugger, which # headless-shell binds to loopback inside the container) and services accept # only an image plus options. This mirrors `mise run e2e`, which is the local # equivalent — keep the two in step. The task itself isn't reused because it # assumes a developer's .env. - name: Start headless browser run: | docker run -d --rm --name mt-headless -p 9222:9222 \ --add-host=host.docker.internal:host-gateway \ --entrypoint bash chromedp/headless-shell:latest -c \ 'socat TCP4-LISTEN:9222,fork TCP4:127.0.0.1:9223 & exec /headless-shell/headless-shell --no-sandbox --use-gl=angle --use-angle=swiftshader --remote-debugging-address=0.0.0.0 --remote-debugging-port=9223 "--host-resolver-rules=MAP *.host.docker.internal host.docker.internal"' # Bounded readiness wait (~20s) so a container that never comes up can't hang. for _ in $(seq 1 40); do curl -sf http://127.0.0.1:9222/json/version >/dev/null 2>&1 && break sleep 0.5 done # The harness defaults (127.0.0.1:9222 for the debugger, host.docker.internal # for how the browser reaches the test server) match the container above, so # no E2E_* overrides are needed. - run: go test -tags browser ./internal/e2e/ -timeout 15m - if: always() run: docker rm -f mt-headless publish: # Every gating check, and deliberately not e2e. Adding a check job means adding # it here; TestBuildDependsOnEveryGatingJob fails if you forget. needs: [lint, test, vuln, licenses] if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read packages: write # For the provenance attestation below. id-token: write attestations: write env: # Lowercase literal rather than github.repository: GHCR paths must be # lowercase, and this repository's name is mixed case. KO_DOCKER_REPO: ghcr.io/meshtender/meshtender # ko is a build input — a different ko can lay out layers differently — so its # version is pinned here and checked against the mise pin. KO_VERSION: v0.19.1 outputs: image: ${{ steps.build.outputs.image }} digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v7.0.0 with: go-version-file: go.mod # Caches the module cache AND the build cache, keyed on go.sum, shared # across jobs and runs. Every job below compiles the same module, so this # is the difference between each one starting cold and starting warm. cache-dependency-path: go.sum # The official release binary, which is the same artifact mise installs locally # (its aqua backend downloads the release, it does not build ko either) — so CI # and a developer now run byte-identical ko. Compiling it from source here cost # ~50s per publish and bought nothing: what affects the image is ko's VERSION, # which decides layer layout, plus the Go on PATH that ko invokes to build the # app. The Go that built ko itself never touches the artifact. - uses: ko-build/setup-ko@v0.10 with: version: ${{ env.KO_VERSION }} # An unresolved version input would leave setup-ko installing something other # than the pin, silently — and an unpinned ko is an unreproducible image. Fail # here instead, where the message says so. - run: | if ! ko version | grep -q "${KO_VERSION#v}"; then echo "ko is $(ko version), want ${KO_VERSION#v} — the pinned version did not install" >&2 exit 1 fi # setup-ko only logs in when KO_DOCKER_REPO is unset, and it is set above, so # authenticate explicitly. - run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin - name: Build and push id: build run: | # --bare publishes to exactly $KO_DOCKER_REPO instead of appending a # package-name/hash suffix. --image-refs records the published reference # INCLUDING its digest, which is the value the deployer needs: a binary # cannot derive its own image digest, so it has to be handed in as # MESHTENDER_IMAGE_DIGEST for /version to report it. if [ "${GITHUB_REF_TYPE}" = "tag" ]; then # Strip a leading "v" so v1.2.3 -> 1.2.3. tags="${GITHUB_REF_NAME#v},latest" else # The commit SHA is the immutable handle; "main" is the moving tag a # deployer watches, resolving it to a digest before rolling out. tags="${GITHUB_SHA},main" fi ko build --bare --tags "$tags" --image-refs image-ref --sbom=spdx ./cmd/meshtender ref="$(cat image-ref)" echo "image=${ref%@*}" >> "$GITHUB_OUTPUT" echo "digest=${ref#*@}" >> "$GITHUB_OUTPUT" { echo "### Published" echo echo '```' echo "$ref" echo '```' echo echo "Deploy by digest and pass the same digest as \`MESHTENDER_IMAGE_DIGEST\`." } >> "$GITHUB_STEP_SUMMARY" # Signed provenance tying this digest to this workflow and commit. The point # of a reproducible build is that an outsider can check our claims; this lets # them check the publisher too, without trusting the registry. - uses: actions/attest-build-provenance@v4.2.2 with: subject-name: ${{ steps.build.outputs.image }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true