name: ci # One workflow for the whole pre-merge gate, with the jobs split so that a failure # names the thing that broke instead of "the build". # # The job set is deliberately the same as `make verify`, and each job calls the # Makefile rather than repeating the commands. A CI script that differs from the # Makefile eventually differs in a way nobody notices, and then the Makefile stops # being a usable local gate. on: push: branches: [main] pull_request: merge_group: # A new push to the same branch makes the in-flight run irrelevant: cancelling it # saves runner minutes and stops an old result being reported against new code. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true permissions: contents: read env: # Matches the Go directive in go.mod. setup-go would read it from there, but # pinning it here means a toolchain bump shows up as a reviewable diff here too. GO_VERSION: "1.27.1" NODE_VERSION: "24" jobs: go: name: go runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} # The module and build caches live in the runner's cache rather than in # the workspace, so a checkout never carries a stale artefact into a test. cache: true - name: Verify formatting run: make fmt-check - name: Vet run: go vet ./cmd/... ./internal/... - name: Lint run: make lint-go env: # golangci-lint caches analysis keyed on the Go version and the source. GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint - name: Test with the race detector run: make test-go - name: Build run: make build-go web: name: admin-ui runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: web/package-lock.json - name: Install # `npm ci`, never `npm install`: it installs exactly the lockfile, and it # fails when the lockfile and package.json disagree instead of quietly # resolving something new. run: npm ci --no-audit --no-fund working-directory: web - name: Lint run: make lint-web - name: Test run: make test-web - name: Build run: make build-web sdk: name: runtime-sdk runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: sdk/package-lock.json - name: Install run: npm ci --no-audit --no-fund working-directory: sdk - name: Lint and typecheck run: make lint-sdk - name: Test run: make test-sdk - name: Build run: make build-sdk helm: name: helm runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: azure/setup-helm@v5 with: version: v3.19.0 - name: Lint strictly # --strict turns warnings into errors, which is what stops a chart shipping # with an icon or maintainer field quietly missing. run: helm lint --strict deploy/helm/go-bananas - name: Render the whole configuration matrix # Not `helm lint`: lint only sees the default values. This renders every # documented configuration and checks both that each valid one produces # parseable YAML and that each invalid one is refused with a message # naming the value to change. run: make helm-matrix contract: name: openapi runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: The document must be valid OpenAPI 3.1 # A specification that does not parse is worse than no specification, # because every client generator will trust it. run: npx --yes @redocly/cli@latest lint openapi.yaml container: name: container runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: docker/setup-buildx-action@v3 # Load the image into the local daemon so the steps after this can run it. # Without `load` the result only exists in the build cache, and there is # nothing to inspect. - name: Build uses: docker/build-push-action@v6 with: context: . file: Dockerfile push: false load: true tags: go-bananas:ci cache-from: type=gha cache-to: type=gha,mode=max - name: The image must actually serve run: | set -eu docker run -d --name bananas \ -e BANANAS_ENVIRONMENT=development \ -e BANANAS_AUTH_MODE=dev \ -e BANANAS_PUBLIC_BASE_URL=http://localhost:8080 \ -p 8080:8080 \ go-bananas:ci # Poll rather than sleeping a fixed time: a fixed sleep is either too # short on a loaded runner or wasted time on a fast one. for i in $(seq 1 60); do if curl -fsS http://localhost:8080/healthz >/dev/null 2>&1; then echo "healthy after ${i}s" break fi if [ "$i" -eq 60 ]; then echo "the container never became healthy" docker logs bananas exit 1 fi sleep 1 done # The admin UI must be served from the image, not just the API: a # container that starts and then 404s on its own dashboard is a # regression the health check would not catch. curl -fsS http://localhost:8080/rt/v1/manifest.json >/dev/null curl -fsS http://localhost:8080/api/v1/auth/me | grep -q '"mode"' echo "--- running as ---" docker exec bananas id # The image declares USER 65532, so anything else means the build lost it. test "$(docker exec bananas id -u)" = "65532" - name: Logs on failure if: failure() run: docker logs bananas || true - name: Clean up if: always() run: docker rm -f bananas || true release-config: name: release-config runs-on: ubuntu-latest # Validating the release configuration on every push is cheap, and a broken # goreleaser config discovered at tag time is a release that cannot be cut. steps: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Install GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: v2.18.2 args: check styling: name: shared styling runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} # The two checks that cannot be done in jsdom: whether the rendered contract CSS # resolves to the right computed values, and whether an application that has not # touched its stylesheet actually follows the contract. The second is the one that # decides whether the consolidation is real. - name: Contract and controls run: make check-contract - name: go-loose pilot run: make check-pilot security: name: security runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Dependency review uses: actions/dependency-review-action@v4 with: # Only on pull requests: the API needs a base branch to compare against, # and it is the pull request that the review is protecting. fail-on-severity: moderate all-green: name: all green # The single required status check. It depends on every job above and does # nothing itself, so that a branch can be made to require one stable name # instead of a list that has to be edited every time a job is added. if: always() needs: [go, web, sdk, helm, contract, container, styling, release-config, security] runs-on: ubuntu-latest steps: - name: Fail if any job failed run: | results='${{ toJSON(needs) }}' echo "$results" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' if echo "$results" | jq -e 'to_entries | any(.value.result != "success")' >/dev/null; then echo "one or more jobs did not succeed" exit 1 fi echo "all jobs succeeded"