-
Notifications
You must be signed in to change notification settings - Fork 1
458 lines (436 loc) · 19.3 KB
/
Copy pathci-cd.yml
File metadata and controls
458 lines (436 loc) · 19.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
name: CI-CD
permissions:
contents: write
pull-requests: write
issues: write
on:
pull_request:
paths:
- 'libs/**'
push:
branches: [master]
paths:
- 'libs/**'
workflow_dispatch:
env:
SKIP_SIMPLE_GIT_HOOKS: 1
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# One paths-filter pass produces three signals:
# - `src-changed`: any `libs/*/src/**` touched → run unit tests.
# Pure docs/CHANGELOG/PERFORMANCE updates inside a lib don't trip this.
# - `perf-changed`: a perf-critical hot path or a benchmark file changed
# → run benches on top of unit tests. Most PRs and most master pushes
# skip this. A perf-affecting change that doesn't touch the filter's
# paths goes uncaught; refresh `perf-baseline.json` when that bites.
# - `libs`: which lib directories changed at all → CD matrix entries.
# semantic-release still has the final say (a `docs:` commit produces
# no release), so this is just an upper bound on which jobs to spin up.
changes:
runs-on: ubuntu-latest
outputs:
src-changed: ${{ steps.src.outputs.src }}
perf-changed: ${{ steps.perf.outputs.perf }}
libs: ${{ steps.libs.outputs.changes }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: src
with:
filters: |
src:
- 'libs/*/src/**'
- uses: dorny/paths-filter@v4
id: perf
with:
# Hot paths are enforced by directory convention, not an
# explicit file list:
# - `libs/*/src/internal/**` — non-public pipeline code
# (drain, correlate, settle, close, event-sourcing,
# lru-map, etc.). The right home for perf-sensitive
# helpers that aren't public API.
# - `libs/*/src/adapters/**` — Store/Cache/Logger impls.
# The in-memory adapters are exercised by every bench.
# - `libs/*/{bench,scripts}/**` — bench files and the
# bench-runner scripts themselves.
# - `libs/*/perf-baseline.json` — baseline refreshes.
# Code that doesn't fit a convention either lives in
# `internal/` (the right place for perf-sensitive helpers)
# or accepts that it won't be auto-bench-validated. The
# convention is the contract; expand it by moving files
# into one of these dirs, not by amending the workflow.
filters: |
perf:
- 'libs/*/src/internal/**'
- 'libs/*/src/adapters/**'
- 'libs/*/bench/**'
- 'libs/*/scripts/**'
- 'libs/*/perf-baseline.json'
- uses: dorny/paths-filter@v4
id: libs
with:
filters: |
act-patch: ['libs/act-patch/**']
act: ['libs/act/**']
act-http: ['libs/act-http/**']
act-pg: ['libs/act-pg/**']
act-sqlite: ['libs/act-sqlite/**']
act-pino: ['libs/act-pino/**']
act-otel: ['libs/act-otel/**']
act-notify: ['libs/act-notify/**']
act-ops: ['libs/act-ops/**']
act-crypto: ['libs/act-crypto/**']
act-diagram: ['libs/act-diagram/**']
act-tck: ['libs/act-tck/**']
# RFC gate (#1061). Enforces the `rfcs/` process mechanically: if a PR grows
# the public-surface stability snapshot (new export / builder method / port
# method / lifecycle event) without adding or linking an `rfcs/NNNN-*.md`, it
# fails. Pure renames/removals and non-surface diffs pass — the charter and
# the snapshot diff itself already cover those. Runs on PRs only; needs full
# history to diff against the base branch. Dependency-free (git + node), so no
# install/build step. See scripts/check-rfc-gate.mjs and rfcs/README.md.
rfc-gate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: enforce RFC gate for public-surface additions
env:
BASE_REF: origin/${{ github.base_ref }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_BODY: ${{ github.event.pull_request.body }}
# Fetch the body live so an exempt marker (or RFC link) added to the
# PR description after opening takes effect on a plain job rerun —
# the event payload is frozen at trigger time and would miss it.
# Falls back to the payload if the API call fails.
run: |
LIVE_BODY="$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '.body // ""' || true)"
PR_BODY="${LIVE_BODY:-$EVENT_BODY}" node scripts/check-rfc-gate.mjs
ci:
needs: changes
if: needs.changes.outputs.src-changed == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5431:5432
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
with:
node-version: 22.23.2
cache: "pnpm"
- run: pnpm install
# Fail fast if a package added/removed/renamed a subpath `export`
# without regenerating the workspace `paths` map (ticket #1059).
# `pnpm paths:sync` rewrites tsconfig.workspace.json from the
# `exports` fields; this check enforces they stay in lockstep.
- run: pnpm paths:check
- run: pnpm typecheck
# `pnpm typecheck` no longer builds first (it resolves cross-package
# imports to `src` via tsconfig.workspace.json), so build explicitly to
# keep declaration-emit / tsup coverage on PRs.
- run: pnpm build
- run: pnpm test
# Browser-safety gate. A published subpath that client code imports
# (`@rotorsoft/act-http/sse`) must not statically reach a Node API:
# #1423 added `import { log } from "@rotorsoft/act"` to the server half
# of that barrel, which put the framework — and the AsyncLocalStorage it
# builds at module scope — into every browser bundle touching it. Nothing
# here builds a browser bundle, so it sat for ten days. Walks `dist/`, so
# it runs after build.
- run: pnpm check:browser-safe
# Section-presence check for libs/*/README.md — ticket #751.
# Fails the build if any lib README is missing one of the required
# canonical sections (Title + tagline, Why, Installation, Quick start,
# Related, Documentation, License). Deprecated packages opt out via
# the `canonical-check: deprecated` HTML-comment marker.
- run: pnpm check:readmes
# `act` CLI smoke test — non-interactive `-q` query against both
# example apps. Catches regressions in the parser, builder, or
# formatter that would silently break the doc surface. NO_COLOR
# neutralises picocolors so greps can match the plain detail view.
- name: act-cli smoke (calculator)
env:
NO_COLOR: "1"
FORCE_COLOR: "0"
run: |
set -o pipefail
pnpm act packages/calculator -q Incremented | tee act-cli-calculator.log
grep -q "^Incremented$" act-cli-calculator.log
grep -q "status:" act-cli-calculator.log
- name: act-cli smoke (wolfdesk)
env:
NO_COLOR: "1"
FORCE_COLOR: "0"
run: |
set -o pipefail
pnpm act packages/wolfdesk -q TicketOpened | tee act-cli-wolfdesk.log
grep -q "^TicketOpened$" act-cli-wolfdesk.log
grep -q "status:" act-cli-wolfdesk.log
# Render the v8 coverage summary as a markdown table in the PR/run
# summary. `json-summary` reporter is enabled in vite.config.ts.
- name: Coverage summary
if: always()
run: |
node -e '
const fs = require("fs");
const f = "coverage/coverage-summary.json";
if (!fs.existsSync(f)) { console.log("No coverage summary found"); process.exit(0); }
const t = JSON.parse(fs.readFileSync(f, "utf8")).total;
const row = (label, m) => `| ${label} | ${m.pct.toFixed(2)}% | ${m.covered}/${m.total} |`;
const md = [
"## Coverage",
"",
"| Metric | % | Covered / Total |",
"|---|---:|---:|",
row("Statements", t.statements),
row("Branches", t.branches),
row("Functions", t.functions),
row("Lines", t.lines),
"",
].join("\n");
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n");
'
# Perf regression guard. Compares a fresh bench run against the
# checked-in baseline; fails if any scenario's p50 exceeds 1.5× baseline.
# To refresh: `pnpm -F @rotorsoft/act bench:update` in a PR labeled
# `perf-baseline-update` with rationale in PERFORMANCE.md.
#
# Benches run only when a perf-critical file changed (see the `perf`
# filter above). Most PRs and most master pushes skip the bench
# steps entirely. If a perf-sensitive change lands without touching
# the filter's paths, it will not be auto-detected — keep the file
# list in sync as new hot paths emerge.
- run: pnpm -F @rotorsoft/act bench:run
if: needs.changes.outputs.perf-changed == 'true'
- run: pnpm -F @rotorsoft/act bench:check
if: needs.changes.outputs.perf-changed == 'true'
# Per-adapter perf gate on REAL adapters (act-1031). Unlike the core
# gate above (InMemory), these run against the docker Postgres on
# 5431 and a real on-disk SQLite — the only place adapter-level
# regressions (a lost index, a reintroduced N+1, a snapshot-floor
# read that scans the whole stream) actually surface.
#
# Budgets: p50 ≤ 2.0× baseline for act-pg (wide noise band on a real
# DB), p50 ≤ 1.5× for act-sqlite (embedded, tighter). Both skip the
# ratio check when the baseline p50 is below a 1.0 ms absolute floor
# (sub-ms ops are noise-dominated). See each package's perf-check.ts.
#
# BLOCKING (#1094): a budget breach fails the PR. Baselines were
# generated on this CI runner via the perf-baseline.yml workflow, so they
# match the hardware the gate runs on — the laptop-vs-runner mismatch that
# report-only guarded against is gone, and the 2.0×/1.5× tolerances + the
# 1.0 ms floor absorb run-to-run variance. If a scenario proves noisy,
# refresh the baseline (re-run perf-baseline.yml) or widen its budget —
# don't silence the gate. The pg connection needs no extra env:
# PostgresStore defaults (host/db/user/password = postgres) match the
# docker service.
- name: act-pg perf gate
if: needs.changes.outputs.perf-changed == 'true'
run: |
set -o pipefail
pnpm -F @rotorsoft/act-pg bench:run
pnpm -F @rotorsoft/act-pg bench:check 2>&1 | tee bench-pg.log
- name: act-sqlite perf gate
if: needs.changes.outputs.perf-changed == 'true'
run: |
set -o pipefail
pnpm -F @rotorsoft/act-sqlite bench:run
pnpm -F @rotorsoft/act-sqlite bench:check 2>&1 | tee bench-sqlite.log
- name: Append adapter perf results to step summary
if: |
always() &&
needs.changes.outputs.perf-changed == 'true'
run: |
{
echo "## Adapter perf gate"
echo ""
echo "Real-adapter p50 regression budgets — act-pg 2.0×, act-sqlite"
echo "1.5×, both with a 1.0 ms absolute floor. A breach fails the PR."
echo ""
echo "### act-pg"
echo '```'
sed -E $'s/\x1b\\[[0-9;]*m//g' bench-pg.log 2>/dev/null || echo "(no act-pg output)"
echo '```'
echo "### act-sqlite"
echo '```'
sed -E $'s/\x1b\\[[0-9;]*m//g' bench-sqlite.log 2>/dev/null || echo "(no act-sqlite output)"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Coveralls upload is best-effort. Outages have blocked merges on
# master before — we'd rather lose a coverage badge than lose a
# release. `continue-on-error: true` lets the rest of the pipeline
# advance regardless of coveralls availability.
- uses: coverallsapp/github-action@v2
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Scenario benchmarks run in their own job rather than inline in `ci`
# (#1551). They were the single longest step on the critical path — longer
# than the whole test suite — and share nothing with the rest of `ci`, so
# running them alongside it rather than after cuts the wall time by their
# full duration. They keep gating releases: `cd` waits on this job.
#
# The scenario benches exercise the real Postgres adapter (per the
# benchmarking rule in CLAUDE.md), so this job needs the same service `ci`
# has.
bench:
needs: changes
if: needs.changes.outputs.perf-changed == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5431:5432
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
with:
node-version: 22.23.2
cache: "pnpm"
- run: pnpm install
# Scenario benchmarks (Shape C). Each one has a built-in
# regression assertion, so a real perf regression breaks the
# build. The captured stdout ends up in the workflow's step
# summary so reviewers see the numbers without scrolling logs.
- name: bench:scenarios
env:
# vitest/node emit ANSI color codes even when stdout is piped,
# which renders as `␛[32m'…'␛[39m` garbage inside the markdown
# code block we append to the step summary. NO_COLOR disables
# color emission at the Node level.
NO_COLOR: "1"
FORCE_COLOR: "0"
run: |
set -o pipefail
pnpm bench:scenarios 2>&1 | tee bench-scenarios.log
- name: Append scenario bench results to step summary
if: always()
run: |
{
echo "## Scenario benchmark results"
echo ""
echo '```'
# Extract the console.table blocks from the captured run.
# Scenario benches print `=== <header> ===` then the table;
# the closing `└` line is the bottom corner of the table.
# Single-process vitest keeps output prefix-free, so the
# match is purely structural. `sed` strips any stray ANSI
# escapes that slipped past NO_COLOR (belt and suspenders).
if grep -qE '=== .+ ===' bench-scenarios.log 2>/dev/null; then
awk '
/=== .+ ===/ { printing=1 }
printing { print }
/└/ && printing { printing=0; print "" }
' bench-scenarios.log \
| sed -E $'s/\x1b\\[[0-9;]*m//g'
else
tail -n 80 bench-scenarios.log
fi
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
cd:
# Run on master when at least one lib directory changed, regardless of
# whether CI ran. `ci` skips on pure docs/CHANGELOG updates inside libs
# — semantic-release-monorepo decides on its own whether a release is
# warranted, so a skipped CI still gets us a clean no-op CD when the
# commit type isn't releasable. `always()` is required because `needs:`
# by default treats a skipped upstream as a block.
if: |
always() &&
github.ref == 'refs/heads/master' &&
needs.changes.outputs.libs != '[]' &&
(needs.ci.result == 'success' || needs.ci.result == 'skipped') &&
(needs.bench.result == 'success' || needs.bench.result == 'skipped')
needs: [changes, ci, bench]
runs-on: ubuntu-latest
# Job-level permissions REPLACE the workflow-level block, so the existing
# grants are restated here alongside the new one. `id-token: write` lets
# npm mint a Sigstore provenance attestation on publish (issue #1142) —
# the OIDC token proves the package was built by this workflow run.
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
strategy:
max-parallel: 1 # prevents concurrent pull/push conflicts on main branch
fail-fast: false
matrix:
lib: ${{ fromJson(needs.changes.outputs.libs) }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
with:
node-version: 22.23.2
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- run: pnpm install
# Build everything from source. We used to ship a `libs/*/dist`
# artifact from CI and download it here, but `actions/upload-artifact`
# strips the longest common ancestor (`libs/`), so the dist files
# landed at `<root>/<lib>/dist` instead of `<root>/libs/<lib>/dist`
# — and `pnpm publish` shipped 11 versions across 7 libs with no
# `dist/` at all (LICENSE + README + package.json only). Rebuilding
# here costs ~30s per matrix job and removes the whole class of
# bug. `pnpm -r build` respects topological order.
- run: pnpm build
- name: configure git
run: |
git config user.name "rotorsoft"
git config user.email "rotorsoft@outlook.com"
- name: pull latest master
run: git pull origin master --rebase
# Publishes with npm TRUSTED PUBLISHING — no long-lived token. pnpm
# exchanges the job's OIDC identity (the `id-token: write` permission
# above) for a short-lived npm token scoped to the package being
# published, which npm grants only to the repo + workflow filename
# registered as that package's Trusted Publisher on npmjs.com. A
# publish from anywhere else has no identity to trade.
#
# `NPM_TOKEN` / `NODE_AUTH_TOKEN` were removed with the migration
# (npm_migration.md). Note pnpm attempts the OIDC exchange whether or
# not a static token is present, so their absence is not what enables
# this — the Trusted Publisher registration is. Adding a token back
# would only reinstate a credential that can leak.
- name: release ${{ matrix.lib }}
working-directory: libs/${{ matrix.lib }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Provenance attestation (issue #1142). Trusted publishing turns
# this on by default; kept explicit so the intent survives a change
# of publish path, and because `pnpm publish` delegates to the npm
# CLI, which reads NPM_CONFIG_* from the environment.
NPM_CONFIG_PROVENANCE: true
run: npx semantic-release