Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ name: check

# The format + lint gate. Reusable (workflow_call), called by BOTH ci.yml and release.yml so prettier +
# eslint block EVERY path a change can land through: pull requests (including docs-only PRs, where the
# build/test matrix is skipped) and release tags (including a --skip-tests prerelease). The husky
# pre-commit hook runs the same checks locally, but a hook is advisory (--no-verify exists) — this is
# the hard gate.
# test matrix is skipped) and release tags. The husky pre-commit hook runs the same checks locally, but a
# hook is advisory (--no-verify exists) — this is the hard gate.
#
# Why it builds + runs codegen first: ESLint here is type-aware (typescript-eslint projectService),
# `@point0/*` types resolve to each package's built dist/, and the examples import generated code — so
# lint needs a real build and `bun run setup` to behave exactly like a local `bun run lint`.
# It does NOT build: the caller's `build` job already produced the single `dist` artifact, and this job
# downloads it — one build per run, and the lint runs against the exact bytes the tests (and npm) get.
# ESLint here is type-aware (typescript-eslint projectService): `@point0/*` types resolve to each package's
# built dist/, and the examples import generated code — hence the artifact + `bun run setup` below, so it
# behaves exactly like a local `bun run lint` in a built tree.
on:
workflow_call:

Expand All @@ -25,23 +26,21 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@v6
# tsdown's build step runs on Node (the framework runtime is Bun).
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# The single ubuntu-built `dist` (build.yml) — type-aware lint resolves @point0/* from it.
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: dist
path: .
# dist is already in place, so this install also relinks the `point0` bin (codegen needs it).
- name: Install dependencies
uses: ./.github/actions/install
# Type-aware lint resolves @point0/* from the built dist/, and codegen needs the built CLI.
- name: Build
run: bun run build
# Bun links the workspace `point0` bin at install time only if its dist target exists. The first
# install ran before the build, so relink now — without this, setup fails with
# "point0: command not found".
- name: Relink workspace bins
uses: ./.github/actions/install
- name: Setup (codegen for examples/templates)
run: bun run setup
- name: Format check (prettier)
Expand Down
86 changes: 45 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@ name: CI
# The test GATE. Runs the cross-OS suite on pull requests and on opt-in branch pushes. It NEVER
# publishes — publishing lives in release.yml and is reachable only via a `v*` tag.
#
# The pipeline is one linear path, decide → build → check → test → gate:
# decide what should run (scripts/ci-decide.ts) + the test plan (scripts/test.ts)
# build ONCE, ubuntu — the single `dist` artifact every later job consumes
# check prettier + eslint on the artifact (no second build)
# test the cross-OS matrix from the plan (skipped when decide says so, e.g. a docs-only PR)
# gate the single required status check
#
# Triggers:
# pull_request → main the real gate: runs the full matrix (forks run WITHOUT secrets), UNLESS the PR
# is docs-only (EVERY changed file is *.md) — then decide returns an empty matrix
# and only `check` (prettier + eslint) and the `gate` run. The single required
# check is `gate`.
# push (not main) a maintainer branch can opt in to a run via a commit-message flag
# (--run-tests[=os]); without it, only `decide` and `check` run.
# and only build + check run under the gate.
# push (not main) a maintainer branch can opt in to a test run via a commit-message flag
# (--run-tests[=os]); without it, only build + check run.
# `main` is deliberately NOT gated on push: it only changes via an already-tested PR or a release
# commit (which is gated by its tag), so a push-to-main gate would just re-test redundantly — and
# would defeat `--skip-tests` on a release commit. The branch-filtered `push` also means tag pushes
# fall through to release.yml only, never here.
# commit (which is gated by its tag). The branch-filtered `push` also means tag pushes fall through
# to release.yml only, never here.
on:
pull_request:
branches: [main]
Expand All @@ -29,15 +34,17 @@ concurrency:
cancel-in-progress: true

jobs:
# Single source of truth for "what should run": branch/event + commit-message flags → the OS matrix.
# Safety invariants live in scripts/ci-decide.ts (unit-tested). This job never publishes.
# Single source of truth for "what should run": branch/event + commit-message flags → the OS matrix
# (scripts/ci-decide.ts, unit-tested) + the test-distribution plan (scripts/test.ts, validated against
# the tree — a stale plan or a mis-suffixed test file fails HERE, loudly). This job never publishes.
decide:
name: decide
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
oses: ${{ steps.d.outputs.oses }}
slow: ${{ steps.d.outputs.slow }}
groups: ${{ steps.d.outputs.groups }}
solo: ${{ steps.d.outputs.solo }}
steps:
# fetch-depth: 0 so the docs-only diff below has both the PR base and head commits available.
- uses: actions/checkout@v6
Expand Down Expand Up @@ -70,58 +77,55 @@ jobs:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
run: bun scripts/ci-decide.ts >> "$GITHUB_OUTPUT"

# Prettier + ESLint (check.yml), on EVERY run — no `needs`, no `if`, so it still gates a docs-only PR
# where the build/test matrix is skipped. The pre-commit hook is advisory (--no-verify); this is the
# hard gate.
check:
uses: ./.github/workflows/check.yml

# Build ONCE (ubuntu-only) → `dist` artifact. The test shards download it instead of each building per-OS,
# so every runner exercises the exact bytes that ship.
# Build ONCE (ubuntu-only) → `dist` artifact. Runs on EVERY gate run — `check` needs it even on a
# docs-only PR (type-aware lint resolves @point0/* from dist), and the test shards download it instead
# of each building per-OS, so every runner exercises the exact bytes that ship.
build:
needs: decide
if: ${{ needs.decide.outputs.oses != '[]' }}
uses: ./.github/workflows/build.yml

# Prettier + ESLint (check.yml) on the built artifact — one build per run, not two. Runs on EVERY gate
# run, including docs-only PRs where the test matrix is skipped. The pre-commit hook is advisory
# (--no-verify exists); this is the hard gate.
check:
needs: build
uses: ./.github/workflows/check.yml

test:
needs: [decide, build]
if: ${{ needs.decide.outputs.oses != '[]' }}
uses: ./.github/workflows/test.yml
with:
oses: ${{ needs.decide.outputs.oses }}
slow: ${{ needs.decide.outputs.slow }}
groups: ${{ needs.decide.outputs.groups }}
solo: ${{ needs.decide.outputs.solo }}

# The single required status check. Because check/build/test are reusable-workflow calls, skipping them (a
# docs-only PR) makes their nested contexts NEVER report — a bare required `build / build` would then hang
# the PR forever. This gate always runs, so exactly one context is always present: it passes when the
# stages succeeded OR were legitimately skipped, and fails if any actually failed/was cancelled.
# `check` is the exception: it runs unconditionally, so anything but success fails the gate.
# Make the ruleset require `gate` (+ `decide`) instead of `build / build` and the `test / fast …` shards.
# The single required status check. Because build/check/test are reusable-workflow calls, skipping one (a
# docs-only PR skips `test`) makes its nested contexts NEVER report — a bare required `test / fast …` would
# then hang the PR forever. This gate always runs, so exactly one context is always present: decide, build
# and check must SUCCEED (they run on every gate run); test must succeed or be legitimately skipped.
# Make the ruleset require `gate` (+ `decide`) instead of the nested contexts.
gate:
name: gate
needs: [decide, check, build, test]
needs: [decide, build, check, test]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require decide and check green and no stage failure
- name: Require decide+build+check green and test green-or-skipped
env:
DECIDE: ${{ needs.decide.result }}
CHECK: ${{ needs.check.result }}
BUILD: ${{ needs.build.result }}
CHECK: ${{ needs.check.result }}
TEST: ${{ needs.test.result }}
run: |
echo "decide=$DECIDE check=$CHECK build=$BUILD test=$TEST"
if [ "$DECIDE" != "success" ]; then
echo "::error::decide did not succeed ($DECIDE)"; exit 1
fi
# check is never legitimately skipped — anything but success fails the gate.
if [ "$CHECK" != "success" ]; then
echo "::error::check (prettier + eslint) did not succeed ($CHECK)"; exit 1
fi
for r in "$BUILD" "$TEST"; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "::error::a required stage failed ($r)"; exit 1
echo "decide=$DECIDE build=$BUILD check=$CHECK test=$TEST"
for stage in "decide:$DECIDE" "build:$BUILD" "check:$CHECK"; do
if [ "${stage#*:}" != "success" ]; then
echo "::error::${stage%%:*} did not succeed (${stage#*:})"; exit 1
fi
done
echo "gate ok — stages passed or were skipped (docs-only)"
if [ "$TEST" != "success" ] && [ "$TEST" != "skipped" ]; then
echo "::error::test did not succeed ($TEST)"; exit 1
fi
echo "gate ok — stages passed (test skipped is fine, e.g. a docs-only PR)"
49 changes: 23 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ name: release
# branch push (a fork cannot push a tag to this repo), which makes "no untrusted code ever publishes" a
# structural guarantee, not a convention. Versions are bumped + tagged together by `bun run release`.
#
# tag v1.2.3 stable → full test matrix (MANDATORY) → publish, dist-tag `latest`
# tag v1.2.3-next.N prerelease → tests unless the release commit says --skip-tests → publish, dist-tag `next`
#
# The dist-tag and the stable/prerelease split are derived from the version (scripts/publish.ts), and the
# Same linear pipeline as the gate, with publish at the end: decide → build → check → test → publish.
# EVERY tag — stable or prerelease — runs the full matrix; there is no skip flag (--skip-tests died in the
# CI rework: when a prerelease must ship despite a broken suite, fix it locally, don't publish untested
# bytes). The dist-tag (`latest`/`next`) is derived from the version (scripts/publish.ts), and the
# tag↔version match is asserted before anything is published (scripts/check-channel.ts).
on:
push:
Expand All @@ -16,22 +16,22 @@ on:

permissions:
contents: read
id-token: write # OIDC: npm provenance / trusted publishing (active once packages are public)
id-token: write # OIDC: npm provenance / trusted publishing

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false # never cancel a publish mid-flight

jobs:
# Same brain as the gate: decide the OS matrix. On a tag it returns the full matrix for a stable
# release (flags ignored) or honors --skip-tests for a prerelease. publish is gated separately below.
# Same brain as the gate: the OS matrix + the test plan. On a tag it always returns the full matrix.
decide:
name: decide
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
oses: ${{ steps.d.outputs.oses }}
slow: ${{ steps.d.outputs.slow }}
groups: ${{ steps.d.outputs.groups }}
solo: ${{ steps.d.outputs.solo }}
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
Expand All @@ -45,36 +45,33 @@ jobs:
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: bun scripts/ci-decide.ts >> "$GITHUB_OUTPUT"

# Prettier + ESLint (check.yml), on EVERY tag — no `needs`, no `if`, so a --skip-tests prerelease
# (test skipped) is still blocked from publishing unformatted/unlinted code.
check:
uses: ./.github/workflows/check.yml

# Build ONCE (ubuntu-only) → `dist` artifact, consumed by BOTH test and publish. Runs on every tag with no
# oses gate, so a --skip-tests prerelease (oses=[], test skipped) still has the exact artifact to publish —
# the publish job no longer builds its own, so what ships is byte-identical to what the matrix tested.
# Build ONCE (ubuntu-only) → `dist` artifact, consumed by check, test AND publish — what ships is
# byte-identical to what was checked and tested.
build:
needs: decide
uses: ./.github/workflows/build.yml

# Prettier + ESLint on the artifact — a tag can't publish unformatted/unlinted code.
check:
needs: build
uses: ./.github/workflows/check.yml

test:
needs: [decide, build]
if: ${{ needs.decide.outputs.oses != '[]' }}
uses: ./.github/workflows/test.yml
with:
oses: ${{ needs.decide.outputs.oses }}
slow: ${{ needs.decide.outputs.slow }}
groups: ${{ needs.decide.outputs.groups }}
solo: ${{ needs.decide.outputs.solo }}

publish:
needs: [decide, check, build, test]
# Publish when `decide` + `check` + `build` succeeded AND the gate is GREEN or was deliberately SKIPPED (a
# prerelease with --skip-tests → the test job never runs → result 'skipped'). Unlike `test`, `check` is
# never legitimately skipped — it must be exactly 'success'. Never on a failed/cancelled gate, a failed
# build, or a failed `decide`. `!cancelled()` is required so this still evaluates when `test` was skipped
# (a skipped dependency would otherwise skip this job too).
# Publish only when the WHOLE pipeline is green — decide, build, check and test all succeeded. Tags
# always test (ci-decide invariant 1), so there is no legitimately-skipped stage here, and any
# failure/cancellation upstream blocks the publish.
needs: [decide, build, check, test]
if:
${{ !cancelled() && needs.decide.result == 'success' && needs.check.result == 'success' && needs.build.result ==
'success' && (needs.test.result == 'success' || needs.test.result == 'skipped') }}
${{ needs.decide.result == 'success' && needs.build.result == 'success' && needs.check.result == 'success' &&
needs.test.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/test-one.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: test one

# Point-run a SINGLE test file on a real CI runner — the flake-debugging tool. Instead of pushing a branch
# and burning the whole matrix to reproduce one flaky file, dispatch this with the file path and the OS:
#
# gh workflow run test-one.yml --ref <branch> -f file=packages/engine/tests/rsc.e2e.test.tsx -f os=ubuntu-latest
# gh workflow run test-one.yml --ref <branch> -f file=... -f os=... -f repeat=5 # flake hunt: N runs in one job
#
# Runs the same steps as a real test-solo leg (built artifact, guarded runner), so a repro here is a repro
# of the release-matrix environment. Never publishes; plain branch permissions.
on:
workflow_dispatch:
inputs:
file:
description: 'Test file to run (repo-relative, e.g. packages/engine/tests/rsc.e2e.test.tsx)'
required: true
type: string
os:
description: 'Runner OS'
required: true
type: choice
options: [ubuntu-latest, windows-latest, macos-latest]
default: ubuntu-latest
repeat:
description: 'How many times to run the file (flake hunting)'
required: false
type: string
default: '1'

permissions:
contents: read

jobs:
build:
uses: ./.github/workflows/build.yml

test-one:
name: ${{ inputs.file }} (${{ inputs.os }}) ×${{ inputs.repeat }}
needs: build
runs-on: ${{ inputs.os }}
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: dist
path: .
- name: Install dependencies
uses: ./.github/actions/install
- name: Setup (codegen for examples/templates)
run: bun run setup
- name: Install Playwright chromium (Linux)
if: ${{ runner.os == 'Linux' && contains(inputs.file, '.e2e.') }}
run: bunx playwright install --with-deps chromium
- name: Install Playwright chromium (Windows/macOS)
if: ${{ runner.os != 'Linux' && contains(inputs.file, '.e2e.') }}
run: bunx playwright install chromium
# LIVE_TEST_OUTPUT so a hang shows exactly where it stopped, unbuffered — this is a debugging run.
- name: Run ${{ inputs.file }} ×${{ inputs.repeat }}
run: |
for i in $(seq 1 ${{ inputs.repeat }}); do
echo "::group::run $i/${{ inputs.repeat }}"
LIVE_TEST_OUTPUT=1 bun scripts/test.ts --file '${{ inputs.file }}' || { echo "::endgroup::"; echo "::error::run $i failed"; exit 1; }
echo "::endgroup::"
done
Loading