Files
pulse/.github/workflows/build-and-test.yml
T
pulse-triage[bot] 1740bccd61 Test issue metadata on proposed source before publication
Run the locked parser and silent triage policy on their complete input changes in a credential-free Build and Test job. Retain issue-event pre-token checks, existing runtime and strict dependency gates; exercise admission and original install/test failure exits.

Change-source: pulse-maintainer
2026-10-14 11:12:45 +01:00

844 lines
37 KiB
YAML

name: Build and Test
on:
push:
branches:
- main
- pulse/v6-release
- 'release/v*'
pull_request:
branches:
- main
- pulse/v6-release
- 'release/v*'
workflow_dispatch:
permissions:
contents: read
# Let branch runs finish so busy main and release branches retain verdicts.
# A newer PR commit supersedes its previous validation, so do not queue the
# replacement behind obsolete checks. Keep this aligned with Core E2E.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
secret-scan:
name: Secret Scan
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
GITLEAKS_VERSION: "8.30.1"
GITLEAKS_LINUX_X64_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Install gitleaks
run: |
set -euo pipefail
archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${archive}"
curl -fsSLo "/tmp/${archive}" "$url"
echo "${GITLEAKS_LINUX_X64_SHA256} /tmp/${archive}" | sha256sum -c -
tar -xzf "/tmp/${archive}" -C /tmp gitleaks
install /tmp/gitleaks /usr/local/bin/gitleaks
- name: Validate delivered-history secret scan
run: python3 scripts/tests/test_ci_secret_scan.py
- name: Run gitleaks
env:
EXPECTED_HEAD: ${{ github.sha }}
DIFF_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
# A merge's latest log entry can have no patch. Scan all newly
# delivered commits and its first-parent resolution, not just -1.
# Missing/non-ancestor bases scan the complete checkout history.
run: |
bash scripts/scan-ci-secrets.sh "$EXPECTED_HEAD" "$DIFF_BASE"
# The secret scan above runs on every push including docs-only ones — this
# workflow is the only per-push gitleaks pass, so it must not be path
# filtered away. The build/test jobs below are gated here instead: a push
# that touches only docs/ and Markdown skips them entirely. Detection fails
# open (code=true) whenever the base commit cannot be resolved.
changes:
name: Detect changed areas
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
code: ${{ steps.filter.outputs.code }}
frontend_deps: ${{ steps.filter.outputs.frontend_deps }}
go_compile: ${{ steps.filter.outputs.go_compile }}
triage_helper: ${{ steps.filter.outputs.triage_helper }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
# Reject deterministic docs drift before queuing builds/tests. Keep the
# independent per-push secret scan and Public docs check unchanged.
- name: Check shipped docs before dependency work
run: python3 scripts/check_docs_mirror.py
- name: Validate browser receipt-only CI admission
run: python3 scripts/tests/test_ci_browser_proof_only.py
- name: Classify changed files
id: filter
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
else
base="${{ github.event.before }}"
fi
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$base" 2>/dev/null; then
echo "Base commit unavailable; assuming code and dependencies changed."
echo "code=true" >> "$GITHUB_OUTPUT"
echo "frontend_deps=true" >> "$GITHUB_OUTPUT"
echo "go_compile=true" >> "$GITHUB_OUTPUT"
echo "triage_helper=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Receipts change evidence, not the application. Keep the per-push
# secret scan and Canonical Governance (including receipt validation).
# The complete tree diff must be receipt-only; mixed source or graph
# changes and an unavailable diff never enter this path.
if [ "$(python3 scripts/ci_browser_proof_only.py --base "$base" --head "${{ github.sha }}")" = true ]; then
echo "Browser receipt-only tree diff; runtime checks are not applicable."
echo "code=false" >> "$GITHUB_OUTPUT"
echo "frontend_deps=false" >> "$GITHUB_OUTPUT"
echo "go_compile=false" >> "$GITHUB_OUTPUT"
echo "triage_helper=false" >> "$GITHUB_OUTPUT"
exit 0
fi
changed=$(git diff --name-only "$base" "${{ github.sha }}")
printf 'Changed files:\n%s\n' "$changed"
# The issue-event job tests before minting its App token, but that
# happens after publication. Test its parser and silent-mutation
# policy on the proposed source too, including documentation-only
# guidance and every workflow consumed by the policy tests.
triage_helper=false
if printf '%s\n' "$changed" | grep -E '^\.github/scripts/(issue-version-triage(\.test)?\.cjs|triage-comment-policy\.test\.cjs|package(-lock)?\.json)$|^\.github/ISSUE_TEMPLATE/|^\.github/workflows/|^docs/ISSUE_TRIAGE\.md$|^scripts/tests/test_ci_browser_proof_only\.py$' >/dev/null; then
triage_helper=true
fi
echo "triage_helper=$triage_helper" >> "$GITHUB_OUTPUT"
# Note: -q must not be combined with -v here — BSD grep exits 1 for
# that combination even when inverted matches exist.
if printf '%s\n' "$changed" | grep -vE '^docs/|\.md$' >/dev/null; then
code=true
else
code=false
fi
echo "code=$code" >> "$GITHUB_OUTPUT"
# A full race-test compilation cost 386 seconds in a fresh offline
# guest. Reserve that admission for Go source/graph/backend embeds
# and its own control path, not the much more frequent UI changes.
go_compile=false
if [ "$code" = true ]; then
go_compile=$(printf '%s\n' "$changed" | bash scripts/go-test-compile-required.sh)
fi
echo "go_compile=$go_compile" >> "$GITHUB_OUTPUT"
# Whether this change moves the frontend dependency graph. It decides
# whether the audit blocks it: a change that moves the graph answers
# for any finding and for an unreachable endpoint, while one that
# leaves the graph alone only warns, because its findings are the
# base commit's. A change to the audit runner itself also demands a
# clean result, so it can never be relaxed under cover of its own
# tolerant mode.
# Consume the full list: grep -q can close the pipe on an early
# match, making printf fail with SIGPIPE under pipefail. A large
# dependency change must not enter the inherited-advisory mode.
if printf '%s\n' "$changed" | grep -E '^frontend-modern/package(-lock)?\.json$|^scripts/(npm-audit-retry\.sh|npm_audit_process\.py)$' >/dev/null; then
frontend_deps=true
else
frontend_deps=false
fi
echo "frontend_deps=$frontend_deps" >> "$GITHUB_OUTPUT"
triage-helper:
name: Issue metadata and silent triage policy
needs: changes
if: needs.changes.outputs.triage_helper == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Check out proposed triage source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
- name: Install locked parser without lifecycle scripts
run: npm ci --ignore-scripts --no-audit --no-fund --prefix .github/scripts
- name: Test proposed issue metadata and silent policy
run: npm run test:triage --prefix .github/scripts
provider-pair-docker:
name: Provider pair Docker acceptance
needs: changes
# Security backports need a live, exact-branch result before the steward
# freezes a release packet. Candidate qualification repeats the same case
# against the verified release image after freeze.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/') && needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout exact release-line source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Verify exact release-line source
env:
EXPECTED_SOURCE_SHA: ${{ github.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA"
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- name: Preload pinned Docker helper image
run: docker pull 'alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6'
- name: Prove provider pair provisioning and cleanup
env:
PULSE_RUN_PROVIDER_PAIR_DOCKER_INTEGRATION: '1'
PULSE_DOCKER_INTEGRATION_IMAGE: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
run: |
set -euo pipefail
go test -count=1 -timeout 3m ./internal/cloudcp/docker \
-run '^TestIntegrationProviderPairNetworkIsolation$' -v \
| tee "$RUNNER_TEMP/provider-pair-network-test.log"
grep -q '^--- PASS: TestIntegrationProviderPairNetworkIsolation ' \
"$RUNNER_TEMP/provider-pair-network-test.log"
frontend:
name: Frontend
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
# Everything except the audit runs in ~11m. The audit is now bounded to a
# 4 minute wall-clock budget, so 30 leaves real headroom while staying a
# meaningful ceiling: an unbounded 40 would have absorbed the 2026-09-04
# stall instead of reporting it.
timeout-minutes: 30
env:
FRONTEND_DIR: frontend-modern
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
cache-dependency-path: frontend-modern/package-lock.json
# npm overrides can install an older serializer than a new Solid asks
# for without npm ci failing. The old security exception is source-bound.
- name: Check Solid serialization dependency edges
run: |
node --test scripts/tests/check-frontend-serialization.test.mjs
node scripts/check-frontend-serialization.mjs
- name: Install frontend dependencies
working-directory: frontend-modern
# The explicit bounded audit below owns the advisory verdict. Avoid
# npm ci's duplicate best-effort audit POST and its hidden timeout.
run: npm ci --no-audit
# npm audit exits 1 both for a real advisory and for an unreachable
# advisory endpoint. The runner retries the endpoint being down, fails a
# change that moves the dependency graph on any finding, and only warns,
# naming every finding, for a change whose graph is the base commit's;
# see the script header.
#
# Only the complete graph is audited here. `--omit=dev` audits a subset
# of these packages, so it can only report a subset of these advisories,
# under the same blocking rule; a separate production step could never
# block a change the complete audit lets through. The dev-versus-production split is still reported
# for every workspace by the scheduled npm-audit job in
# security-scan.yml, where it informs rather than blocks delivery.
- name: Audit complete frontend dependency graph
id: audit-complete
continue-on-error: true
working-directory: frontend-modern
env:
NPM_AUDIT_REQUIRE_RESULT: ${{ needs.changes.outputs.frontend_deps }}
run: bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" all
# Whole-tree, not staged-only: the pre-commit formatter only ever sees
# staged files, so drift in untouched files is invisible to it. This is
# the backstop that keeps `make format` a no-op on a clean tree.
- name: Check frontend formatting
working-directory: frontend-modern
run: npm run format:check
- name: Lint frontend
working-directory: frontend-modern
run: npm run lint
- name: Audit header composition
working-directory: frontend-modern
run: npm run lint:headers
- name: Check frontend copy-paste duplication
working-directory: frontend-modern
run: npm run lint:cpd
- name: Type-check frontend
working-directory: frontend-modern
run: npm run type-check
# Reject an unshippable bundle before the whole-tree unit suite. On
# PR2624 that suite took 8m45s before the size gate rejected the bundle.
# Passing builds still require the same tests, audit and size budgets.
- name: Build frontend bundle (with embed copy)
run: make frontend
- name: Check frontend bundle size budget
working-directory: frontend-modern
run: npm run check:bundlesize
- name: Frontend unit tests
working-directory: frontend-modern
run: npm run test
# Use this checkout's production assets, not another job's embed stub.
# Keep the existing job deadline, audit and full frontend tests gating.
- name: Set up Go for real-embed verification
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- name: Verify backend with real frontend embed
run: |
set -euo pipefail
checkout_sha=$(git rev-parse HEAD)
printf 'REAL_EMBED_CHECKOUT=%s\n' "$checkout_sha"
test -s frontend-modern/dist/index.html
test -d frontend-modern/dist/assets
diff -qr --no-dereference frontend-modern/dist internal/api/frontend-modern/dist
go version
go build -mod=readonly -p=2 -o "$RUNNER_TEMP/pulse-real-embed" ./cmd/pulse
go vet -mod=readonly -p=2 ./cmd/pulse ./internal/api
sha256sum "$RUNNER_TEMP/pulse-real-embed"
# #2785: confined manager doubles cannot establish native recovery.
# Reuse this job's real embed/cache on a fresh hosted VM, not a release,
# customer LXC, privileged worker or new maintainer dispatcher.
- name: Check current-source unattended recovery on native systemd
env:
EXPECTED_SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA"
git diff --quiet HEAD
diff -qr --no-dereference frontend-modern/dist internal/api/frontend-modern/dist
go build -mod=readonly -p=2 -ldflags='-X main.Version=v0.0.1' -o "$RUNNER_TEMP/pulse-native-before" ./cmd/pulse
go build -mod=readonly -p=2 -ldflags='-X main.Version=v0.0.2' -o "$RUNNER_TEMP/pulse-native-after" ./cmd/pulse
sudo env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 \
GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted GITHUB_REPOSITORY=rcourtman/Pulse \
EXPECTED_SOURCE_SHA="$EXPECTED_SOURCE_SHA" RUNNER_TEMP="$RUNNER_TEMP" \
python3 scripts/installtests/auto_update_systemd.py \
--before "$RUNNER_TEMP/pulse-native-before" --after "$RUNNER_TEMP/pulse-native-after" \
--receipt "$RUNNER_TEMP/pulse-native-updater.json"
- name: Retain bounded native updater receipt
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-updater-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/pulse-native-updater.json
if-no-files-found: warn
retention-days: 7
# Reuse the exact real-embed binary, rather than another build or mock
# lifecycle callback. This owns only disposable loopback endpoints/data:
# saved policy must reach ordinary alerts and survive a real restart.
- name: Check ordinary notification policy and restart
timeout-minutes: 3
run: >-
python3 scripts/check_notification_runtime.py
--binary "$RUNNER_TEMP/pulse-real-embed"
--expected-source-sha "${{ github.sha }}"
--receipt "$RUNNER_TEMP/pulse-notification-runtime.json"
- name: Retain bounded notification runtime receipt
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: notification-runtime-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/pulse-notification-runtime.json
if-no-files-found: warn
retention-days: 7
# An audit service failure or real advisory stays gating, but it must not
# suppress formatting, tests, type-checking, or production-build proof.
- name: Require frontend dependency audit
if: ${{ !cancelled() }}
env:
COMPLETE_AUDIT_RESULT: ${{ steps.audit-complete.outcome }}
run: |
if [ "${COMPLETE_AUDIT_RESULT}" != success ]; then
echo "::error::The frontend dependency audit failed."
exit 1
fi
# Reject cross-package test/API incompatibilities before spending seven
# runners on race tests. This links test binaries but never executes them;
# the existing required checks below still own runtime and vet verdicts.
test-compile:
name: Compile backend race tests (no execution)
needs: changes
if: needs.changes.outputs.go_compile == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout repository
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
cache: true
- name: Provide frontend test embed stub
run: bash scripts/ensure_test_assets.sh
- name: Compile every Go race-test binary without executing it
run: bash scripts/compile-go-tests.sh
backend:
name: Backend tests (${{ matrix.shard }})
needs: [changes, test-compile]
# Expand the unchanged required checks even when compilation fails. A
# skipped dependency must not turn a broken source into a green check.
if: ${{ !cancelled() && needs.changes.result == 'success' }}
# Expand every required shard even for documentation-only changes. A
# job-level condition skips expansion and leaves the named checks pending.
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# internal/api has its own sharded job below. Measured weights balance
# every other whole package across these two unchanged required checks.
shard: [rest-0, rest-1]
steps:
- name: Require compiled backend tests
env:
COMPILE_REQUIRED: ${{ needs.changes.outputs.go_compile }}
COMPILE_RESULT: ${{ needs.test-compile.result }}
run: |
if [ "$COMPILE_REQUIRED" = true ] && [ "$COMPILE_RESULT" != success ]; then
echo "::error::Backend test compilation did not pass (result: ${COMPILE_RESULT:-unknown}); no runtime tests started."
exit 1
fi
- name: Report documentation-only change
if: needs.changes.outputs.code != 'true'
run: echo "No backend code changed. Tests are not required for this shard."
- name: Checkout repository
if: needs.changes.outputs.code == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
if: needs.changes.outputs.code == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
# The internal/api package embeds frontend-modern/dist. Unit tests do
# not assert on real bundle content, so a stub file satisfies the embed
# without the ~5 minute frontend build; the real bundle is built and
# size-gated by the frontend job, and release artifacts always build it.
- name: Provide frontend embed stub
if: needs.changes.outputs.code == 'true'
run: |
mkdir -p internal/api/frontend-modern/dist
[ -f internal/api/frontend-modern/dist/index.html ] || \
printf '<!doctype html><title>ci embed stub</title>\n' > internal/api/frontend-modern/dist/index.html
- name: Go unit tests (shard ${{ matrix.shard }})
if: needs.changes.outputs.code == 'true'
env:
PULSE_DATA_DIR: /tmp/pulse-test-data
# Source, not the timing file, defines coverage. Longest measured
# packages go to the lightest shard and start first within that shard;
# new packages use a default weight. Do not leave slow binaries behind
# the alphabetically earlier small packages in Go's parallel scheduler.
# Package binaries are independent, so only internal/api needs the
# contiguous, order-preserving test slices used in its separate job.
run: |
set -euo pipefail
case "${{ matrix.shard }}" in
rest-0) index=0 ;;
rest-1) index=1 ;;
*) echo "unknown shard ${{ matrix.shard }}" >&2; exit 1 ;;
esac
pkgs=$(go list ./... | grep -v '/internal/api$' \
| python3 .github/scripts/select-go-package-shard.py \
.github/scripts/go-package-test-seconds.txt 2 "$index")
if [ -z "$pkgs" ]; then
echo "shard resolved to an empty package list" >&2
exit 1
fi
printf 'Packages in this shard:\n%s\n' "$pkgs"
go test -race -timeout 50m $pkgs
# internal/api alone took ~27 of the ~31 minutes of the old single
# `Backend tests (api)` job under -race, which made it the critical path of
# every pull request. Its top-level tests are split across these shards
# as contiguous runs of the package's own test order, cut by estimated run
# time, and the `Backend tests (api)` job below keeps the required check
# name by passing only when every shard passed.
backend-api:
name: Backend tests (api-${{ matrix.index }})
needs: [changes, test-compile]
# Expand the unchanged required checks even when compilation fails. A
# skipped dependency must not turn a broken source into a green check.
if: ${{ !cancelled() && needs.changes.result == 'success' }}
# Like the rest shards, expand even for documentation-only changes so the
# verdict job sees real shard results rather than skipped jobs.
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# Keep API_SHARD_COUNT equal to the number of indexes listed here.
index: [0, 1, 2, 3, 4]
env:
API_SHARD_COUNT: 5
steps:
- name: Require compiled backend tests
env:
COMPILE_REQUIRED: ${{ needs.changes.outputs.go_compile }}
COMPILE_RESULT: ${{ needs.test-compile.result }}
run: |
if [ "$COMPILE_REQUIRED" = true ] && [ "$COMPILE_RESULT" != success ]; then
echo "::error::Backend test compilation did not pass (result: ${COMPILE_RESULT:-unknown}); no runtime tests started."
exit 1
fi
- name: Report documentation-only change
if: needs.changes.outputs.code != 'true'
run: echo "No backend code changed. Tests are not required for this shard."
- name: Checkout repository
if: needs.changes.outputs.code == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
if: needs.changes.outputs.code == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- name: Provide frontend embed stub
if: needs.changes.outputs.code == 'true'
run: |
mkdir -p internal/api/frontend-modern/dist
[ -f internal/api/frontend-modern/dist/index.html ] || \
printf '<!doctype html><title>ci embed stub</title>\n' > internal/api/frontend-modern/dist/index.html
- name: Go unit tests (internal/api shard ${{ matrix.index }})
if: needs.changes.outputs.code == 'true'
env:
PULSE_DATA_DIR: /tmp/pulse-test-data
API_SHARD_INDEX: ${{ matrix.index }}
# The test list is computed from the checked-out commit at run time,
# so every shard sees the same list and a test added later lands in
# exactly one shard with no workflow edit. The list keeps go test's
# own order (file name, then declaration), which is also run order,
# and each shard takes one contiguous slice of it. Some internal/api
# tests still lean on package state left by the tests just before
# them (for example admin bypass or session store globals), and an
# interleaved split by name broke dozens of them, while contiguous
# slices keep those neighbours together. The slices are cut by
# estimated run time from .github/scripts/internal-api-test-seconds.txt
# rather than by count, because a handful of tests take most of the
# time (one contract test runs about six minutes and the integration
# server tail about twenty seconds per test). Unlisted tests get a
# small default weight, and the weights only move the cut points, so
# coverage never depends on them. Listing with -race builds the same
# test binary the run then reuses from cache. Benchmarks are left out
# because plain go test never runs them. The run goes through
# -json so every shard records each test's seconds on the runner
# (uploaded below and fed back into the weights by
# refresh-internal-api-test-seconds.py). The recorder prints only
# package lines and the output of failing or unfinished tests, like
# plain go test, and pipefail keeps a go test failure fatal.
run: |
set -euo pipefail
listing=$(go test -race -list . ./internal/api)
tests=$(printf '%s\n' "$listing" | grep -E '^(Test|Fuzz|Example)[^[:space:]]*$' || true)
if [ -z "$tests" ]; then
echo "::error::go test -list found no tests in ./internal/api"
exit 1
fi
total=$(printf '%s\n' "$tests" | wc -l | tr -d ' ')
selected=$(printf '%s\n' "$tests" | bash .github/scripts/select-internal-api-shard.sh \
.github/scripts/internal-api-test-seconds.txt "$API_SHARD_COUNT" "$API_SHARD_INDEX")
if [ -z "$selected" ]; then
echo "::error::internal/api shard ${API_SHARD_INDEX} of ${API_SHARD_COUNT} resolved to an empty test list"
exit 1
fi
others=$(printf '%s\n' "$tests" | grep -vxF -f <(printf '%s\n' "$selected") || true)
# Name whichever side is shorter. A shard holding most of the tests
# is passed as -skip of every other shard's tests, which runs the
# same set in the same order. Never pair -skip with -run. The test
# binary caches only the last compiled pattern, so alternating the
# two recompiles the long skip pattern for every test and subtest,
# which added about seven minutes to such a shard.
pattern="^($(printf '%s\n' "$selected" | paste -sd '|' -))\$"
filter=(-run "$pattern")
if [ -n "$others" ]; then
skip="^($(printf '%s\n' "$others" | paste -sd '|' -))\$"
if [ "${#skip}" -lt "${#pattern}" ]; then
pattern=$skip
filter=(-skip "$pattern")
fi
fi
# Linux rejects a single argument over 128 KiB. Fail with a clear
# instruction instead of an opaque exec error.
if [ "${#pattern}" -gt 120000 ]; then
echo "::error::internal/api shard pattern is ${#pattern} bytes; add a shard index and raise API_SHARD_COUNT"
exit 1
fi
echo "internal/api shard ${API_SHARD_INDEX} of ${API_SHARD_COUNT} runs $(printf '%s\n' "$selected" | wc -l | tr -d ' ') of ${total} tests (${selected%%$'\n'*} .. ${selected##*$'\n'})"
timings="$RUNNER_TEMP/internal-api-test-seconds"
mkdir -p "$timings"
# Bind the observed runtime stream to this exact source-derived
# selection. A package pass alone cannot prove a shard ran its tests.
# The non-.txt suffix keeps selection names out of timing refreshes.
printf '%s\n' "$selected" > "$timings/api-${API_SHARD_INDEX}.selected"
package=$(go list ./internal/api)
go test -race -timeout 50m -json "${filter[@]}" ./internal/api \
| python3 .github/scripts/record-internal-api-test-seconds.py "$timings/api-${API_SHARD_INDEX}.txt" \
"$timings/api-${API_SHARD_INDEX}.selected" "$package"
- name: Upload internal/api test timings
if: always() && needs.changes.outputs.code == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: internal-api-test-seconds-${{ matrix.index }}
path: ${{ runner.temp }}/internal-api-test-seconds/
if-no-files-found: warn
retention-days: 30
# Required check name. Branch protection matches `Backend tests (api)`
# exactly, so this job must keep that name and must fail unless every
# internal/api shard succeeded (a skipped or cancelled shard fails it too).
backend-api-verdict:
name: Backend tests (api)
needs: backend-api
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require every internal/api shard
env:
API_SHARDS_RESULT: ${{ needs.backend-api.result }}
run: |
if [ "$API_SHARDS_RESULT" != success ]; then
echo "::error::internal/api shards did not all pass (result: ${API_SHARDS_RESULT})"
exit 1
fi
echo "Every internal/api shard passed."
script-smoke:
name: Script smoke tests (shard ${{ matrix.shard }}/2)
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [0, 1]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# test_gitleaks_ignore.py resolves historical fingerprints against
# branch- and tag-reachable commits, so it needs full history.
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- name: Provide frontend embed stub
run: |
mkdir -p internal/api/frontend-modern/dist
[ -f internal/api/frontend-modern/dist/index.html ] || \
printf '<!doctype html><title>ci embed stub</title>\n' > internal/api/frontend-modern/dist/index.html
- name: Script smoke tests
# Isolated runners preserve each recipe's deadlines and process cleanup.
# Discover all suites in both shards; never maintain a second allowlist.
run: scripts/tests/run.sh --smoke-shard ${{ matrix.shard }}
scripts-and-build:
name: Script smoke tests & backend build
needs: [changes, script-smoke]
if: always() && needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Require every script smoke shard
env:
SMOKE_RESULT: ${{ needs.script-smoke.result }}
run: |
if [ "$SMOKE_RESULT" != success ]; then
echo "::error::Script smoke shards did not all pass (result: ${SMOKE_RESULT})"
exit 1
fi
echo "Every script smoke shard passed."
- name: Checkout repository
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
cache: true
- name: Provide frontend embed stub
run: |
mkdir -p internal/api/frontend-modern/dist
[ -f internal/api/frontend-modern/dist/index.html ] || \
printf '<!doctype html><title>ci embed stub</title>\n' > internal/api/frontend-modern/dist/index.html
- name: Build Pulse backend
run: go build ./cmd/pulse
benchmarks:
name: Benchmarks
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Checkout pull request base for paired comparison
if: github.event_name == 'pull_request'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.pull_request.base.sha }}
path: benchmark-base
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- name: Provide frontend embed stub
run: |
mkdir -p internal/api/frontend-modern/dist
[ -f internal/api/frontend-modern/dist/index.html ] || \
printf '<!doctype html><title>ci embed stub</title>\n' > internal/api/frontend-modern/dist/index.html
if [ -d benchmark-base ]; then
mkdir -p benchmark-base/internal/api/frontend-modern/dist
[ -f benchmark-base/internal/api/frontend-modern/dist/index.html ] || \
printf '<!doctype html><title>ci embed stub</title>\n' \
> benchmark-base/internal/api/frontend-modern/dist/index.html
fi
- name: Go benchmarks (non-PR)
if: github.event_name != 'pull_request'
timeout-minutes: 15
env:
PULSE_BENCH_SAMPLE_COUNT: "10"
run: bash scripts/run-ci-benchmarks.sh
# Only packages whose dependency closure the pull request changes are
# sampled. A module or shared-model change still samples nearly every
# package: about 24 minutes, warm-up included, as of October 2026. The
# script's budget stops the run with per-package costs before the step
# timeout can kill it without them.
- name: Paired base and candidate benchmarks (pull requests)
if: github.event_name == 'pull_request'
timeout-minutes: 30
env:
PULSE_BENCH_BASELINE_DIR: ${{ github.workspace }}/benchmark-base
PULSE_BENCH_SAMPLE_COUNT: "10"
PULSE_BENCH_BUDGET_SECONDS: "1680"
run: bash scripts/run-ci-benchmarks.sh
- name: Install benchstat
run: go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260211190930-8161c38c6cdc
- name: Compare benchmarks against baseline
if: github.event_name == 'pull_request'
run: |
set -eo pipefail
echo "=== Benchmark comparison (baseline vs current) ==="
benchstat bench-baseline.txt bench-results.txt | tee bench-comparison.txt
echo ""
bash scripts/check-bench-regression.sh bench-comparison.txt
- name: Upload benchmark evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bench-results
path: |
bench-results.txt
bench-baseline.txt
bench-comparison.txt
bench-metadata.txt
if-no-files-found: warn
retention-days: 7