Skip to content

Gate /api/generate cross-origin, and validate the site header in one … #29

Gate /api/generate cross-origin, and validate the site header in one …

Gate /api/generate cross-origin, and validate the site header in one … #29

Workflow file for this run

# CI: build, the repo's two test tiers, and — only once those pass — everything
# this repo publishes: the clickd container image and, on a tag, the CLI release.
# `go test ./...` is hermetic (every external service is faked
# in-process), so it runs in the static-checks job; the end-to-end shell suites
# need live Docker Compose stacks, and GitHub's runners ship docker compose, so
# CI runs exactly what a developer runs: make up && make smoke, and
# make up-auth && make smoke-auth.
#
# The image build lives here rather than in a workflow of its own so it can
# depend on those jobs directly (needs:) and build the very commit they tested.
# A separate workflow would have to use workflow_run, which resolves its own
# file from the default branch, checks out the default branch unless told
# otherwise, and never fires at all when CI is skipped — so a tag would silently
# get no image. The CLI release (goreleaser) is gated here for the same reason.
name: ci
on:
# Every push to main runs the full suite, deliberately without a path filter.
# main is what the clickd image is built from below, so "tested" has to mean
# the merge commit itself and not just the pull request that preceded it: a
# squash or rebase can differ from the branch that was reviewed, and a direct
# push to main gets no pull-request run at all. A merged PR therefore runs CI
# twice, which is cheap next to shipping an untested image.
#
# Tags get the same treatment for the same reason: the image a rollout pins
# must come from a commit the suites passed. Note there is deliberately no
# `paths`/`paths-ignore` on push — path filters apply to tag pushes too, so a
# tag whose commit only touched ignored files would silently produce no image.
push:
branches: [main]
tags: ["v*"]
# Docs-only PRs can't affect the binary or the stack, so skip the
# (expensive, multi-arch Docker) smoke suites for them. Scope matters:
# `*.md` is root-only (README/ARCHITECTURE/DESIGN/DEPLOY/AGENTS/CLAUDE —
# pure prose), so
# embedded templates keep running CI — internal/gen/agents.md is go:embed'd
# into the binary and is NOT ignored. docs/** is ignored even though
# docs/site-api.md is embedded there: a content edit still compiles, and a
# rename edits embed.go (a .go file, not ignored), so a build-breaking
# change always still runs CI.
pull_request:
paths-ignore:
- '*.md'
- 'docs/**'
permissions:
contents: read
jobs:
# Arch-agnostic static checks: one runner is enough.
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go build ./...
- run: go vet ./...
# The layer above vet — errcheck, staticcheck, ineffassign, unused and
# modernize, per .golangci.yml (`make lint` runs the same thing locally).
# The version is pinned rather than floating: with `latest`, an upstream
# release that adds a check turns the next unrelated PR red, and the
# bisect points at the wrong commit.
- uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
# The Go unit tests are hermetic (every external service is faked
# in-process), so they belong with the static checks rather than with the
# Docker-bound smoke suites.
- run: go test -race ./...
- name: gofmt
run: |
out=$(gofmt -l ./cmd ./internal)
if [ -n "$out" ]; then
echo "gofmt needed on:" && echo "$out"
exit 1
fi
# The smoke suites test the containerized platform itself, so they run on
# both architectures — the compose stack has to work on amd64 and arm64
# (Graviton) nodes alike.
smoke-open:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
# Bringing the stack up pulls six images from Docker Hub, anonymously,
# from a runner IP shared with every other public repo — so it draws
# rate-limit 429s and the occasional 502 that have nothing to do with the
# commit. Retry the bring-up rather than making someone re-run the job.
- name: Start the stack
run: |
for attempt in 1 2 3; do
make up && exit 0
echo "::warning::stack bring-up failed (attempt $attempt/3), retrying"
docker compose down -v || true
sleep $((attempt * 15))
done
exit 1
- run: make smoke
- name: stack logs
if: failure()
run: docker compose logs --tail 200
smoke-auth:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
# Every curl in the suite maps *.click.localhost to loopback with
# --resolve, but `click login` resolves the OIDC issuer through the system
# resolver — a Go program has no --resolve. macOS maps *.localhost to
# loopback natively and systemd-resolved does the same, so this only bites
# in a bare runner container, where the CLI cannot reach dex at all.
- name: Make the OIDC issuer host resolvable
run: |
if ! grep -q 'dex\.click\.localhost' /etc/hosts; then
echo '127.0.0.1 dex.click.localhost' | tee -a /etc/hosts >/dev/null \
|| echo '127.0.0.1 dex.click.localhost' | sudo tee -a /etc/hosts >/dev/null
fi
getent hosts dex.click.localhost
# Same transient-registry retry as smoke-open above; the auth overlay adds
# three more images (dex, oauth2-proxy, libsql-server) to the pull.
- name: Start the auth stack
run: |
for attempt in 1 2 3; do
make up-auth && exit 0
echo "::warning::auth stack bring-up failed (attempt $attempt/3), retrying"
docker compose -f docker-compose.yml -f docker-compose.auth.yml down -v || true
sleep $((attempt * 15))
done
exit 1
- run: make smoke-auth
- name: stack logs
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.auth.yml logs --tail 200
# Build and push the clickd container image to the GitHub Container Registry,
# so a deployment can pin it by tag (see DEPLOY.md / docs/deploy-eks-github.md).
#
# - push to main → clickd:sha-<short-sha> + latest (per-commit image)
# - tag v* → clickd:vX.Y.Z + latest (the tag rollouts pin)
#
# latest always follows the most recent build of either kind.
#
# needs: every test job, so no image ever exists for a commit the suites
# rejected — which matters for any deployment that tracks latest with
# imagePullPolicy: Always, where a pushed image reaches the platform on the
# next pod restart. The event guard keeps pull requests out of the registry
# entirely, and the repository guard keeps forks from trying to push at all.
#
# Multi-arch (amd64 + arm64) so arm64 nodes (e.g. AWS Graviton) can run it.
# The Dockerfile cross-compiles in a native build stage, so no emulated Go —
# QEMU only covers the tiny alpine runtime stage.
image:
needs: [checks, smoke-open, smoke-auth]
if: github.event_name == 'push' && github.repository == 'closeio/click'
runs-on: ubuntu-latest
# GITHUB_TOKEN needs packages:write to push to ghcr.io; contents stays read.
permissions:
contents: read
packages: write
# Job-scoped: a superseded image build is cancelled without disturbing the
# test jobs of the run it belongs to.
concurrency:
group: image-${{ github.ref_name || github.run_id }}
cancel-in-progress: true
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/clickd
steps:
- uses: actions/checkout@v7
- name: Log in to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Compute tags
id: tags
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
tag="$GITHUB_REF_NAME"
else
tag="sha-$(echo "$GITHUB_SHA" | cut -c1-12)"
fi
echo "tags=$IMAGE:$tag,$IMAGE:latest" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
# Keep the index to exactly the two platform manifests: buildx
# attestations add unknown/unknown entries that older containerd
# versions refuse with "no match for platform in manifest".
provenance: false
sbom: false
# Release the click CLI: a v* tag builds the linux amd64/arm64 + macOS arm64
# binaries (.goreleaser.yaml) and publishes them as a GitHub release.
#
# Gated on the same jobs as the image, *and* on the image itself: gating both
# on the tests alone still lets the registry push fail while goreleaser
# succeeds, which advertises a version nobody can run. Publishing the release
# last means a tag never announces binaries whose image is missing. The
# reverse — image pushed, release failed — is left open deliberately: a
# registry tag nothing references is harmless.
#
# The cost is coupling (a ghcr.io outage blocks an otherwise fine CLI release)
# and about a minute of serialization. Note the image job cancels in progress,
# but its concurrency group is keyed on ref_name, so distinct tags never
# collide and a tag build is never superseded out from under this job.
#
# contents: write is scoped to this job rather than the workflow, so the test
# jobs and the image build keep read-only tokens; it is exactly what the
# release upload needs, no PAT.
release:
needs: [checks, smoke-open, smoke-auth, image]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the git-based changelog diffs against the previous tag
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
# GoReleaser cross-compiles every target (CGO is off), so one amd64
# runner builds the linux amd64/arm64 and darwin arm64 binaries alike.
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}