Skip to content

fix(limits): item restore enforces items_per_workspace (BUG-3101) #4349

fix(limits): item restore enforces items_per_workspace (BUG-3101)

fix(limits): item restore enforces items_per_workspace (BUG-3101) #4349

Workflow file for this run

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.6`, 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
- 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.
run: go test -race -timeout=45m ./...
- name: Build binary
run: go build -o pad ./cmd/pad
- name: Verify binary runs
run: ./pad --help
native-smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
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.6 (BUG-2565) 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.6 — 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>'
- 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
}
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:
image: postgres:17-alpine
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.6` 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
- 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).
run: go test -race -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 (Playwright)
runs-on: ubuntu-latest
# 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 + ~190 tests: a clean
# pass is ~8-10m, but a single flaky-test retry tips the total past a
# 10m cap and the job is CANCELLED mid-suite while every test passed —
# a required gate going red with zero code involvement (BUG-2645
# mechanism b: marginal timeout). 15m absorbs one retry while still
# killing a genuine hang. Evidence pair: an 8m10s clean rerun vs a
# 10m18s cancel at the same SHA.
timeout-minutes: 15
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.6` 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
# 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"
run: npx playwright test
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14