mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
1740bccd61
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
844 lines
37 KiB
YAML
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
|