Skip to content

fix(act): unsubscribe from notify before stopping the settle loop #1658

fix(act): unsubscribe from notify before stopping the settle loop

fix(act): unsubscribe from notify before stopping the settle loop #1658

Workflow file for this run

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