Files
pad/.github/workflows/ci.yml
T
xarmian 7c9f29b2f3 fix(ci): Go toolchain 1.26.10 (GO-2026-6809) and markdown-it 14.3.3 (TASK-4007) (#2267)
* fix(ci): Go toolchain 1.26.10 for GO-2026-6809, markdown-it 14.3.3 for npm audit (TASK-4007)

Main CI is red on both gates.
- govulncheck: GO-2026-6809 (crypto/x509) is fixed in go1.26.10. The
  go.mod toolchain line and the Dockerfile's golang image move to
  1.26.10. The workflow comments that name the toolchain value follow
  it, and go.mod's comment records the raise.
- Nix: its artifact is built with nixos-26.05's go, which predates
  1.26.10, so GO-2026-6809 joins nix/accepted-advisories.txt the way the
  1.26.9 block did, with the same removal condition.
- npm audit: markdown-it up to 14.3.2 has two high quadratic-time DoS
  advisories (GHSA-vfgh-jcmw-g37q, GHSA-gfr2-rv44-r23c). The overrides
  pin goes ^14.3.0 -> ^14.3.3; the lock moves markdown-it 14.3.2 ->
  14.3.3 and nothing else.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* ci(deps): allow markdown-it 14.3.3 under the 7-day age check, a security fix (TASK-4007)

Dependency age (advisory) flagged only markdown-it@14.3.3, published
2026-10-16: the fix for GHSA-vfgh-jcmw-g37q and GHSA-gfr2-rv44-r23c.
pad-merge-pr requires every check at the head to be SUCCESS, so the
advisory red blocked the merge. A security fix cannot wait out the
window. The allow line is the file's own remedy for this case; its
removal after the version ages past 7 days is a filed follow-up.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-21 00:07:12 -04:00

808 lines
40 KiB
YAML

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# All third-party Actions are pinned to a 40-char commit SHA with a trailing
# '# vX.Y.Z' comment so a compromised maintainer or moved tag cannot silently
# execute attacker code in CI. Bump the SHA + comment together when updating.
jobs:
go:
# Backstop only. A hung TEST is caught by -timeout=45m on the steps
# below, which fails fast AND prints the goroutine dump that tells
# you which test hung; this cap exists because a job with no
# timeout-minutes inherits GitHub's 6-HOUR default, so a runaway
# that isn't a single test (a wedged service container, a stuck
# download) would otherwise sit there. Set well above the two 45m test
# steps so that in practice the per-binary timeout is what fires — not a
# guarantee, since this cap covers setup and every step, not just those
# two (TASK-2545).
timeout-minutes: 100
name: Go
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# actions/setup-go unconditionally exports GOTOOLCHAIN=local, which
# forbids Go from fetching the toolchain go.mod requires. go.mod pins
# `go 1.26.5`, but setup-go's "1.26" resolves to the newest patch in
# its manifest (1.26.4 here), so every `go` command fails with
# "go.mod requires go >= 1.26.5 (running go 1.26.4)". Written AFTER
# setup-go so it wins the $GITHUB_ENV last-write; `auto` lets Go pull
# what go.mod asks for on demand. Added after #896 raised the go.mod
# floor.
#
# Load-bearing for the govulncheck step below as of BUG-2565: go.mod
# also carries `toolchain go1.26.10`, which is what actually gets
# stamped into the scanned binary and clears 8 reachable stdlib
# advisories. `auto` is what honours that line — under `local` the
# build silently falls back to whatever patch setup-go installed and
# the gate goes red again.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Create web build placeholder for embed
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# The materializer bundle (TASK-2198) is embedded like web/build, but it
# is built for real rather than stubbed: internal/materialize's tests run
# it (TestCorpusParity replays the corpus through it in goja), so a
# placeholder would fail them. Only the bundle, not the whole web build.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
- name: Run go vet
run: go vet ./...
# The Nix vendorHash bump parser (TASK-2954). Its own gate lives HERE, in
# the Go job, and not in nix.yml: over there the script only does anything
# when a build has ALREADY failed on a hash mismatch, so an ordinary PR
# would exercise none of its logic and a change that broke it would land
# green and surface on the next dependency bump. The load-bearing case in
# the suite is negative — an npm fetchurl FOD mismatch must NOT be written
# into vendorHash.
- name: Test the Nix vendorHash bump parser
run: nix/bump-vendor-hash_test.sh
# The heal job that pushes the parser's output to main (BUG-2974). Gated
# here for the same reason and one more: in nix.yml that job runs only
# after a real merge has moved the module set, so its defect shipped green
# for a day and was found by reading run logs. The suite drives the script
# against a real git remote, and its load-bearing case is the one that
# shipped — a heal owed by an EARLIER push, on a push that touched no
# go.mod/go.sum.
- name: Test the Nix vendorHash heal job
run: nix/heal-vendor-hash_test.sh
- name: Run golangci-lint
# only-new-issues: false means CI fails on ANY linter finding,
# not just findings on PR-changed lines. The IDEA-732 cleanup
# (PRs #247/#249/#251/#252) cleared the existing findings under
# the configured linter set in .golangci.yml — staticcheck SA*,
# govet, ineffassign, gofmt, and the standalone `unused` linter
# (which reports U1000). Flipping the gate now prevents
# regression drift going forward.
# v2 of golangci-lint is required because v1 is capped at older
# Go releases that we no longer support.
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.11.4
args: --timeout=5m
only-new-issues: false
# Skip the pre-lint `golangci-lint config verify`, which fetches its
# JSON schema over the network from golangci-lint.run and fails the
# required Go gate when that host is slow/unreachable — no lint
# finding, no code involvement (BUG-2568). A malformed .golangci.yml
# is still caught by the lint run itself, just with a worse message.
verify: false
# Cap the blast radius of a stale-cache event to ~24h. The action's
# cache stores the resolved issue list from a prior pass; if any
# one pass writes degenerate results (analyzer upgrade, plugin
# reset, sub-package version drift), every downstream restore
# replays that list verbatim until the cache key rotates. PR #635
# hit exactly this — 30+ SA5011/SA4023 false positives against
# unchanged code while local (cold-cache) runs reported 0 issues.
# Default is 7 days; 1 day still keeps most runs cache-hot while
# guaranteeing daily refresh. See BUG-1624.
cache-invalidation-interval: "1"
- name: Run govulncheck
# Fails the build on any known vulnerability in a package we
# actually reach via the call graph. Net-positive: catches CVEs
# in indirect deps early, without the noise of hitting every
# stale entry in our dependency tree.
#
# Runs in BINARY mode against a freshly-built pad binary rather than
# source mode (`govulncheck ./...`). Source mode builds an SSA call-
# graph over the whole dependency tree and can balloon to multiple GB
# of RAM (BUG-2084); binary mode reads the binary's symbol table — a
# fraction of the memory, still call-graph-precise, and detects stdlib
# vulns from the Go version stamped in the binary. The "Create web
# build placeholder for embed" step above satisfies the //go:embed so
# the scan build succeeds. Mirrors the Makefile `vuln` target — keep
# the two in sync.
#
# Pinned to a specific govulncheck release. Track upstream in
# Pad's workspace; bump intentionally so an upstream behavior
# change can't break unrelated PRs. Update via:
# go install golang.org/x/vuln/cmd/govulncheck@<new-tag>
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.2.0
go build -o pad-vulnscan ./cmd/pad
"$(go env GOPATH)/bin/govulncheck" -mode binary pad-vulnscan
- name: Run tests
# -timeout is EXPLICIT here on purpose. `go test` without it uses a
# 10m per-test-binary default that nobody chose. The two race steps
# in this file were raised to 45m when the suite outgrew 30m
# (BUG-1913) while their non-race siblings silently kept the
# default — which is how the PostgreSQL leg below panicked at 10m on
# the v0.13.0 release commit (TASK-2545, which also found the ~40
# minutes it cost the cut and the goroutine dump showing nothing
# actually hung). Matching the race
# legs keeps ONE number in this file. It is a hang-catcher, not a
# performance budget: the signal for "the suite got slow" is job
# wall-clock, not this value.
run: go test -timeout=45m ./...
- name: Run tests with race detector
# Runs on both push-to-main AND pull_request. Previously gated to
# main only because GitHub Actions minutes were billed on private
# repos; the repo is public now, so PR minutes are free and we'd
# rather catch race regressions on the contributing branch than
# after merge. See BUG-1371 (also dropped test-only bcrypt cost via
# TestMain, which brought this step back under the then-30m budget —
# it did NOT raise the budget; BUG-1913 later did, 30m→45m, when the
# suite outgrew it again).
#
# Default 10m is tight: the full server-package suite under -race
# measures ~13m locally on a developer laptop after BUG-851 (the
# ipRateLimiter goroutine drain). The PLAN-866 attachment work
# (image decode/encode/resize across thumbnail + transform tests)
# pushes total race-step runtime past 20m on the GitHub-hosted
# runner. BUG-1913: the suite organically grew past the old 30m
# budget (734 server-package tests, ~30.3m under -race even on a
# fast local machine; no single test exceeds 14s — aggregate
# weight, not a hang), turning most main runs red. 45m restores
# headroom; genuine deadlocks still hit this and produce the
# goroutine-dump panic, just up to 15m later. The real fix
# (cheaper suite: shared fixtures / sharding) is tracked on
# BUG-1913.
#
# checkptr is OFF for modernc.org/... only (BUG-3483). -race turns on
# checkptr, which checks unsafe.Pointer arithmetic, and modernc's
# SQLite is C transpiled to Go that does such arithmetic in every VM
# step: measured at 27% of the server package's CPU under -race. Its
# findings would be about a third-party library's translated C, not
# our code. Race detection itself stays on everywhere, modernc
# included, and checkptr stays on for every other package.
# Measured: -25% on 267 serial server tests, -40% on a 40-test probe.
run: go test -race -gcflags='modernc.org/...=-d=checkptr=0' -timeout=45m ./...
- name: Build binary
run: go build -o pad ./cmd/pad
- name: Verify binary runs
run: ./pad --help
dep-age:
# Advisory, NOT a required check (TASK-1391, lead ruling). Dependabot's
# cooldown (TASK-1390) holds a release for 7 days before PROPOSING it, but
# cannot see a version added by hand (`go get`, `npm install`, an edited
# workflow pin). This flags any Go module, npm package or action pin the
# PR ADDS that was published fewer than 7 days ago. A red result is a
# prompt to wait or to allow the version with a reason in
# .github/dep-age-allow.txt, not a merge block. See cmd/depages/README.md.
timeout-minutes: 15
name: Dependency age (advisory)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
# The PR's merge commit, one parent back: HEAD^1 is the base branch tip,
# so the diff against it is exactly what this PR adds.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# The same reason as the Go job above: setup-go exports
# GOTOOLCHAIN=local, and go.mod asks for a newer patch.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Flag dependencies this PR adds that are younger than 7 days
env:
GH_TOKEN: ${{ github.token }}
run: go run ./cmd/depages --days 7 --base HEAD^1
native-smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# 10 for the smoke itself, plus the materializer bundle build and the
# supervisor tests (TASK-2198 U3), which spawn real workers and compile
# the package's test binary.
timeout-minutes: 35
defaults:
run:
shell: pwsh
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
binary: pad
path: ./pad
- os: windows-latest
binary: pad.exe
path: .\pad.exe
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
# No GOTOOLCHAIN=auto step here, unlike every other Go job in this
# file, and the reason is worth stating because the asymmetry looks
# like an oversight: `go-version-file` makes setup-go PARSE go.mod
# itself, and v7's parser prefers the `toolchain` directive over the
# `go` directive, so it installs go1.26.10 (BUG-2565, raised 2026-10-08) directly. The
# GOTOOLCHAIN=local it then exports is satisfied by that install —
# nothing left to fetch. The other jobs pass `go-version: "1.26"`,
# which never reads go.mod at all, so they genuinely need `auto` to
# pick the toolchain line up at build time.
#
# The one way this silently regresses: setup-go's parser falls back
# to the `go` directive when GOTOOLCHAIN is ALREADY `local` in the
# environment at parse time (installer.ts parseGoVersionFile). So if
# a job- or workflow-level `env: GOTOOLCHAIN: local` is ever added
# above this step, these smoke binaries drop to the 1.26.5 floor
# while everything else ships 1.26.10 — no red, just coverage that
# quietly stops matching the artifact.
with:
go-version-file: go.mod
- name: Create web build placeholder
run: |
New-Item -ItemType Directory -Force web/build | Out-Null
Set-Content web/build/index.html '<!doctype html>'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# The materializer bundle (TASK-2198) is built for real here, as in the
# go job, and not stubbed: the supervisor tests at the end of this job
# spawn real workers that load it, and internal/materialize.New refuses
# a placeholder by name. Only the bundle, not the whole web build (the
# go job's shape): web/build stays the placeholder above.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
- name: Build binary
run: go build -o "${{ matrix.binary }}" ./cmd/pad
- name: Verify help output
run: '& "${{ matrix.path }}" --help'
- name: Run CLI smoke test
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$binary = '${{ matrix.path }}'
$env:PAD_DATA_DIR = Join-Path $env:RUNNER_TEMP 'pad-smoke'
$env:PAD_BYPASS_SETUP_TOKEN = 'true'
$stdout = Join-Path $env:RUNNER_TEMP 'pad-server.out.log'
$stderr = Join-Path $env:RUNNER_TEMP 'pad-server.err.log'
$server = Start-Process -FilePath $binary -ArgumentList 'server','start','--port','17777' -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
try {
$ready = $false
for ($attempt = 0; $attempt -lt 60; $attempt++) {
try {
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:17777/api/v1/health | Out-Null
$ready = $true
break
} catch {
Start-Sleep -Seconds 1
}
}
if (-not $ready) {
Get-Content $stdout, $stderr -ErrorAction SilentlyContinue
throw 'Pad server did not become ready'
}
& $binary auth configure --mode local --port 17777
& $binary auth setup --email smoke@example.com --name Smoke --password 'SmokePass123!'
& $binary workspace create Smoke --slug smoke --template startup
$item = (& $binary --workspace smoke --format json item create task 'Native smoke item') | ConvertFrom-Json
$shown = (& $binary --workspace smoke --format json item show $item.ref) | ConvertFrom-Json
if ($shown.title -ne 'Native smoke item') {
throw "Unexpected item title: $($shown.title)"
}
# Stop through the CLI, not Stop-Process: `pad server stop` is
# the only place the Windows PID-file ownership check runs
# (BUG-2969 — flock covers Linux and macOS, GetProcessTimes covers
# this platform), and a smoke that killed the process directly
# would leave that implementation unexercised anywhere. If it
# fails, the smoke fails; the finally below is the backstop that
# keeps a wedged server from holding the runner.
& $binary server stop
if (-not $server.HasExited) {
$server.WaitForExit(10000) | Out-Null
}
if (-not $server.HasExited) {
throw 'pad server stop did not stop the server'
}
} finally {
Stop-Process -Id $server.Id -ErrorAction SilentlyContinue
}
# A headless disarm must hold for as long as its session lives, on both
# platforms, through the REAL owner probe (no Go test runs in this job).
# Windows cannot probe a pid, so the verdict is unknown: before BUG-2771
# that read as dead, and the next read reaped the file and let auto_arm
# forget the disarm. On unix the file named the exiting `pad session
# disarm` instead of the session, so it was dead on arrival (BUG-3227).
# This step's shell stands in for the session, named the way a
# non-Claude harness names it: PAD_SESSION_PID.
- name: Headless disarm holds for the session (BUG-2771, BUG-3227)
run: |
$ErrorActionPreference = 'Stop'
$env:PAD_SESSION_PID = "$PID"
$binary = (Resolve-Path '${{ matrix.path }}').Path
$env:PAD_DATA_DIR = Join-Path $env:RUNNER_TEMP 'pad-arm-smoke'
Remove-Item Env:CLAUDE_CODE_MESSAGING_SOCKET -ErrorAction SilentlyContinue
$repo = Join-Path $env:RUNNER_TEMP 'arm-repo'
New-Item -ItemType Directory -Force $repo | Out-Null
Set-Content (Join-Path $repo '.pad.toml') "workspace = `"demo`"`n[push]`nauto_arm = true`n"
Push-Location $repo
try {
& $binary session should-arm
if ($LASTEXITCODE -ne 0) { throw 'premise: an auto_arm repo with no override must arm' }
$d = & $binary session disarm --format json | ConvertFrom-Json
if (-not (Test-Path $d.path)) { throw 'disarm wrote no arm-state file' }
foreach ($i in 1, 2) {
& $binary session should-arm
if ($LASTEXITCODE -eq 0) { throw "read ${i}: the disarm was forgotten and the session arms via auto_arm" }
if (-not (Test-Path $d.path)) { throw "read ${i} reaped the disarm file" }
}
$r = & $binary session reset --format json | ConvertFrom-Json
if (-not $r.removed) { throw 'pad session reset removed nothing' }
& $binary session should-arm
if ($LASTEXITCODE -ne 0) { throw 'after reset, auto_arm must decide again (armed)' }
} finally {
Pop-Location
}
$global:LASTEXITCODE = 0
# The memory-cap tests below allocate 2 GiB against a 1600 MiB cap, and
# the adversarial one drives a real worker to a 2 GiB cap. Logged so a
# failure there can be read against what the runner actually had.
- name: Runner memory
run: |
if ($IsMacOS) { sysctl hw.memsize hw.ncpu } else { Get-CimInstance Win32_ComputerSystem | Select-Object TotalPhysicalMemory, NumberOfLogicalProcessors | Format-List }
# The materializer supervisor (TASK-2198 U3) on each OS's REAL memory
# cap, which only these runners have: macOS's parent-side RSS watchdog
# (proc_info) and Windows' Job Object with a suspended start. The Go job
# covers Linux's RLIMIT_AS. PAD_MATERIALIZE_TEST_NO_SKIP turns the cap
# tests' skips into failures, so a cap that stopped running cannot pass
# as "skipped".
- name: Materializer supervisor and memory cap (TASK-2198 U3)
env:
PAD_MATERIALIZE_TEST_NO_SKIP: "1"
run: go test ./internal/materialize/ -run '^(TestSupervisor.*|TestParseMemLimit|TestConfigFromEnv|TestParsePSRSS|TestPSRSSLive|TestParseStatVsize|TestStderrOOMMarkers|TestDeepNestFrameShallow)$' -count=1 -timeout 20m -v
go-postgres:
# Backstop only. A hung TEST is caught by -timeout=45m on the steps
# below, which fails fast AND prints the goroutine dump that tells
# you which test hung; this cap exists because a job with no
# timeout-minutes inherits GitHub's 6-HOUR default, so a runaway
# that isn't a single test (a wedged service container, a stuck
# download) would otherwise sit there. Set well above the two 45m test
# steps so that in practice the per-binary timeout is what fires — not a
# guarantee, since this cap covers setup and every step, not just those
# two (TASK-2545).
timeout-minutes: 100
name: Go (PostgreSQL)
runs-on: ubuntu-latest
services:
postgres:
# The official image through Google's Docker Hub mirror, pinned by
# digest. Anonymous pulls from shared runners hit limits on both
# earlier sources: Docker Hub ("toomanyrequests", day 90, #1987 and
# #1988), then AWS's public mirror ("toomanyrequests: Data limit
# exceeded", day 91, #2078, BUG-3602). The digest is postgres:17-alpine's
# multi-arch index, the same on mirror.gcr.io and public.ecr.aws when
# pinned (2026-10-11), so this is the image CI already ran, and a
# moved tag cannot change it under a run. Bump it deliberately.
image: mirror.gcr.io/library/postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
env:
POSTGRES_USER: pad
POSTGRES_PASSWORD: pad
POSTGRES_DB: pad
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pad"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# See the identical step in the `go` job — setup-go pins
# GOTOOLCHAIN=local, blocking the toolchain fetch go.mod requires
# (`go 1.26.5` floor, `toolchain go1.26.10` per BUG-2565).
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Create web build placeholder for embed
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# The materializer bundle (TASK-2198) is embedded like web/build, but it
# is built for real rather than stubbed: internal/materialize's tests run
# it (TestCorpusParity replays the corpus through it in goja), so a
# placeholder would fail them. Only the bundle, not the whole web build.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
# pad's backup and restore shell out to pg_dump and psql, and pg_dump
# refuses to dump a server newer than itself. ubuntu-latest ships the
# 16 clients against this job's 17 server, so the native round trip
# (TestPostgresBackupRestoreConnection) could never run here (TASK-2962).
# The PGDG signing key is checked against its published fingerprint
# before the repository is trusted, in the spirit of the action pins
# above.
- name: Install PostgreSQL 17 clients
run: |
set -euo pipefail
key=/usr/share/keyrings/pgdg.asc
curl -fsSL -o /tmp/pgdg.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
# signed-by trusts EVERY key in the file, so the file must hold
# exactly one primary key and it must be PGDG's.
keys=$(gpg --show-keys --with-colons /tmp/pgdg.asc)
pubs=$(printf '%s\n' "$keys" | awk -F: '$1 == "pub"' | wc -l)
fpr=$(printf '%s\n' "$keys" | awk -F: '$1 == "fpr" { print $10; exit }')
if [ "$pubs" != "1" ] || [ "$fpr" != "B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8" ]; then
echo "PGDG key check failed: $pubs primary key(s), first fingerprint $fpr" >&2
exit 1
fi
sudo install -m 0644 /tmp/pgdg.asc "$key"
echo "deb [signed-by=$key] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
| sudo tee /etc/apt/sources.list.d/pgdg.list > /dev/null
sudo apt-get update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/pgdg.list -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0
sudo apt-get install -y --no-install-recommends postgresql-client-17
echo "/usr/lib/postgresql/17/bin" >> "$GITHUB_PATH"
- name: Check the PostgreSQL clients match the server
run: |
set -euo pipefail
for tool in pg_dump psql; do
"$tool" --version
"$tool" --version | grep -Eq ' 17\.' || { echo "$tool is not version 17" >&2; exit 1; }
done
# Runs the native backup/restore round trip ALONE and verbosely, and
# fails unless it PASSED: in the quiet suite below a skip and a pass
# look the same, which is how this test went unrun in CI. It creates
# and drops its own uuid-named databases, so it needs the superuser the
# service starts with, not the suite's shared database.
- name: Native backup/restore round trip against PostgreSQL
env:
PAD_TEST_POSTGRES_TOOLS_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
run: |
set -euo pipefail
go test ./cmd/pad/ -run '^TestPostgresBackupRestoreConnection$' -count=1 -v | tee /tmp/roundtrip.log
grep -q '^--- PASS: TestPostgresBackupRestoreConnection ' /tmp/roundtrip.log || { echo "round trip did not run to a PASS" >&2; exit 1; }
- name: Run tests against PostgreSQL
env:
PAD_TEST_POSTGRES_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
# Explicit -timeout: see the SQLite "Run tests" step. This is the
# binary that actually blew the 10m default — internal/store creates a
# fresh database AND replays the full migration chain per test
# (~0.43s each locally), so the package's runtime grows linearly with
# the test count. Measured on a dev box at 212d59e7: internal/store
# 280s, whole PG suite 4m43s wall. CI's own runtime is UNMEASURED
# here; all that follows from the panic is that its store binary
# exceeded 10m, i.e. >2.14x this box on that package. (An earlier
# version of this comment said "~2x slower, which put store over
# 10m" — 280s x 2 is 9m20s, so that explained nothing.)
run: go test -timeout=45m ./... -count=1
- name: Run tests with race detector against PostgreSQL
env:
PAD_TEST_POSTGRES_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
# Runs on both push-to-main AND pull_request — see SQLite race-step
# comment for the public-repo / BUG-1371 reasoning.
#
# Headroom over the default 10m. PostgreSQL adds latency on
# every CREATE/DROP, and the PLAN-866 attachment work pushed the
# cumulative wall over 20m. The bootstrap-user bcrypt cost that
# blew past 30m on main (BUG-1371) is now handled by TestMain
# dropping the cost to bcrypt.MinCost for test binaries.
# BUG-1913: raised 30m → 45m alongside the SQLite step — the
# suite's aggregate runtime crossed the old budget (this job
# variant failed main at f235a04 with the same timeout panic).
# checkptr off for modernc.org/... only: see the SQLite race step (BUG-3483).
run: go test -race -gcflags='modernc.org/...=-d=checkptr=0' -timeout=45m ./... -count=1
web:
name: Web
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check coordinated Tiptap pins
# The Y.Doc/ProseMirror schema is shared across @tiptap/core,
# @tiptap/extension-collaboration, @tiptap/y-tiptap and @tiptap/pm.
# A stray `npm update` that slides one of them can silently change
# the persisted Y.Doc shape, producing divergent collab ops the
# relay can't reconcile (see CLAUDE.md "Tiptap multi-package
# coordinated bumps" and BUG-2009). This guard fails if any of them
# loses its exact pin in package.json or resolves to more than one
# version in the lockfile.
run: npm run check:tiptap-pins
- name: Build
run: npm run build
- name: Type check (svelte-check)
run: npm run check
- name: Run web unit tests (vitest)
run: npm run test
- name: Audit npm dependencies (production, high+)
# Fail the build on any HIGH or CRITICAL advisory in production deps.
# Dev-only advisories are treated as informational — they don't ship
# and fixing them can require waiting on upstream maintainers.
#
# LAST, and through scripts/ci-audit.mjs, not bare `npm audit`
# (BUG-2881). Bare `npm audit` exits non-zero the same way for "an
# advisory exists" and "the advisory service was unreachable", and this
# job's shell is `bash -e` — so a registry timeout here used to SKIP
# Build, Type check and vitest and leave a red row that read like the
# web tests caught something, when nothing had run at all (2026-09-04,
# main and #1246, two transports, one bad window). The script decides
# from the JSON report: real advisories fail the step and are named; an
# unreachable service retries, then fails under a DISTINCT title saying
# the gate did not run — closed, not open, because a gate that passes
# when it cannot run is not a gate. Running last means the frontend's
# own verdict always exists, whichever way the audit goes.
run: npm run audit:ci
e2e:
name: E2E ${{ matrix.label }}
runs-on: ubuntu-latest
# IDEA-3189's tripwire fired on 2026-10-05: the median of the last 10
# green E2E jobs was 16.5m (13.7-19.3m) against its 14m threshold, and a
# green-on-rerun job (#1811) was cancelled at the 20m cap with no failing
# test. So the suite is split across jobs, each building for itself.
#
# The cut is per PROJECT, then desktop in halves. A bare `--shard=N/2`
# does not balance: Playwright cuts the ordered test list by COUNT and the
# projects run in sequence, so with both projects the same size each half
# was exactly one project (measured on this unit's first run: shard 2/2
# ran mobile-chromium alone, 63 passed and 553 skipped, in 1.9m). And the
# projects are lopsided: desktop-chromium carried 1222s of the 1359s of
# test time (572 of 635 passing tests). Desktop is therefore sharded in
# two and mobile runs whole. The "E2E (Playwright)" job below is the one
# check that says whether E2E passed.
strategy:
fail-fast: false
matrix:
include:
- label: desktop 1/2
project: desktop-chromium
shard: 1/2
artifact: desktop-1
- label: desktop 2/2
project: desktop-chromium
shard: 2/2
artifact: desktop-2
- label: mobile
project: mobile-chromium
shard: 1/1
artifact: mobile
# Build the binary + UI once and reuse across Playwright projects.
# The cap is a runaway sanity check, not a perf budget. It covers
# build-web + build-binary + install-playwright + the suite. A single
# flaky-test retry (~+2m) past the cap CANCELS the job mid-suite while
# every test passed: a required gate going red with zero code
# involvement (BUG-2645 mechanism b). History:
# - 10 -> 15 (#1158): a green job took 7m54s. Evidence pair: an 8m10s
# clean rerun vs a 10m18s cancel at the same SHA.
# - 15 -> 20 (BUG-3188): measured 2026-09-24, green E2E median 10.9m
# (9.3-11.7m over 5 runs) as the suite grew ~190 -> ~280 tests. The
# growth is new specs; tests present in both runs were not slower.
# Raising the cap treats the symptom. The tripwire is IDEA-3189: shard
# or trim when the median of the last 10 green E2E jobs passes 14m (the
# measurement recipe is on that item). Still kills a genuine hang.
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# See the identical step in the `go` job — setup-go pins
# GOTOOLCHAIN=local, blocking the toolchain fetch go.mod requires
# (`go 1.26.5` floor, `toolchain go1.26.10` per BUG-2565; the
# "Build pad binary" step below fails without this).
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web
run: npm ci
- name: Build web UI
working-directory: web
run: npm run build
- name: Build pad binary
# Web build output is embedded via //go:embed; it must exist before
# the Go build. The CI `go` job above builds against a placeholder,
# which is fine for tests — for e2e we need the real embedded UI.
run: go build -o pad ./cmd/pad
- name: Build the e2etest pad binary
# TASK-3415: the real-install leg starts its own pad from this
# build, which carries the PAD_E2E_APP_CA door (cmd/pad/e2e_app_ca.go)
# so it can trust the test app's CA. Only this build has it.
run: go build -tags e2etest -o pad-e2etest ./cmd/pad
- name: The shipped build has no e2e CA door
# Lead ruling on TASK-3415: no environment variable may widen what a
# default build trusts. The e2etest build is the positive control:
# if it lost the string, the first check would pass vacuously.
run: |
grep -aq PAD_E2E_APP_CA pad-e2etest || { echo "control: the e2etest build has no PAD_E2E_APP_CA"; exit 1; }
if grep -aq PAD_E2E_APP_CA pad; then echo "the default build contains PAD_E2E_APP_CA"; exit 1; fi
# Playwright browser binaries are downloaded from cdn.playwright.dev,
# which occasionally hangs (PR #635 hit two consecutive 10-minute
# timeouts during a CDN slow patch — see BUG-1625). Cache them per
# @playwright/test version so warm-cache runs skip the ~150 MB
# Chromium download entirely; only the apt system libraries
# (libatk, libnss, libcups, …) need a fresh install, which is
# ~10s on a healthy runner. Cache key is the resolved version from
# package-lock.json so a Playwright bump auto-invalidates.
- name: Get Playwright version
id: playwright-version
working-directory: web
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright browsers (cache miss)
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: web
# --with-deps pulls in the Ubuntu libraries Playwright needs
# (libatk, libnss, libcups, …). Scoped to chromium to cut download
# time — the suite's mobile project uses Pixel 7, which defaults to
# Chromium, so we don't need WebKit.
run: npx playwright install --with-deps chromium
- name: Install Playwright system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
working-directory: web
# When the browser binary cache hits, we still need the apt-level
# system libraries on the fresh runner — `install-deps` does just
# that without re-downloading the browser binary itself.
run: npx playwright install-deps chromium
- name: Run Playwright
working-directory: web
env:
CI: "1"
PAD_E2E_BINARY: ${{ github.workspace }}/pad-e2etest
run: npx playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ matrix.artifact }}
path: web/playwright-report/
retention-days: 14
# A test that fails and then passes on retry leaves the job green, so the
# upload above never runs and the flake ships no screenshot, trace or
# video, though retain-on-failure kept them in test-results (BUG-3278).
# Upload them on a green run only when a failed attempt left a trace; a
# run with no flakes uploads nothing.
- name: Upload flaky-test evidence
if: success() && hashFiles('web/test-results/**/trace.zip') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-flaky-evidence-${{ matrix.artifact }}
path: web/test-results/
retention-days: 7
# The single E2E verdict. Every reader of CI (merge rules, waiters, people)
# keys on the check named "E2E (Playwright)", and the repo has no branch
# protection to list the shard jobs by name, so this job keeps that name and
# is green only when EVERY matrix job succeeded. `if: always()` is load-bearing:
# without it a failed or cancelled matrix job SKIPS this job, and a skipped check
# reads as neither red nor missing.
e2e-result:
name: E2E (Playwright)
needs: e2e
if: always()
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Every E2E job succeeded
env:
SHARDS: ${{ needs.e2e.result }}
run: |
echo "e2e matrix result: $SHARDS"
test "$SHARDS" = "success"