Skip to content

fix(components): forward the declared onSuccess block on all four action surfaces #4217

fix(components): forward the declared onSuccess block on all four action surfaces

fix(components): forward the declared onSuccess block on all four action surfaces #4217

Workflow file for this run

name: Lint
# Until #2923 this workflow was `workflow_dispatch`-only, so ESLint had never
# gated a PR. That mattered more than it looked: every `object-ui/*` rule that
# `eslint.config.js` sets to `error` is a ratchet — added *specifically* so a
# new violation fails CI, with its existing sites pre-cleaned first so the rule
# lints clean on the day it lands. While nothing ran them, every one of them was
# inert.
#
# `eslint.config.js` is the single list of those rules, and this comment
# deliberately neither counts them nor names them: it used to hand-count, and
# the count was stale by the time anyone read it (#3261). A hand-copied
# enumeration drifts by construction, and a stale one still reads as
# authoritative — `content/docs/guide/ci-cd-pipeline.md` avoids the number for
# the same reason. `scripts/__tests__/lint-workflow.test.ts` holds that in
# place: it fails if a count or a rule name reappears here, if the config stops
# setting any `object-ui/*` rule to `error`, or if this workflow stops gating
# pull requests.
#
# `--max-warnings` is deliberately not set: warnings repo-wide run into the
# thousands, dominated by `no-explicit-any` plus React Compiler rules the config
# downgrades on purpose — known historical debt, not a signal. This gate is
# about errors. No exact figure here, for the reason above: this paragraph used
# to carry a hand-maintained warning count and percentage that nothing
# recomputed and nothing alarmed on as they aged (#3274), and
# `scripts/check-lint-coverage.mjs` held a copy of the same number that would
# have gone stale on its own clock. The order of magnitude is the whole
# argument; the integer never was.
on:
push:
branches: [main, develop]
paths-ignore:
- '**/*.md'
- 'content/**'
- 'docs/**'
- '.changeset/**'
# No `paths-ignore` here any more (objectui#3523, step 2) — it skipped the
# whole workflow on a docs-only / changeset-only PR, so the `Lint` context was
# absent exactly where a required check must still report. The path decision
# moved into the job below. `push` above keeps its copy: nothing judges a push
# to `main`.
pull_request:
branches: [main, develop]
# ── Merge queue (objectui#3523) ────────────────────────────────────────
# The merge queue is ENFORCED on this repository by a ruleset — a direct push
# to `main` returns 405 `Changes must be made through the merge queue`
# (measured in #3243). Until this trigger landed, not one of the repository's
# workflows subscribed `merge_group`: repo-wide `event=merge_group` runs stood
# at total_count = 0, historically. A queue with nothing subscribed to it can
# only have an EMPTY required-check set, so it rebuilt each PR on the current
# `main` and let it through without validating anything.
#
# That is not a theoretical hole; it was cashed in on 2026-08-07. #3498 landed
# a `scripts/` type gate, itself fully green, that left a TS2578 on `main`;
# #3503, #3510 and #3516 then merged between 02:11Z and 02:15Z with `Type
# Check` at conclusion=failure, and #3505 hot-fixed the result. objectstack
# went through the same frames (objectstack#6067 -> #5615).
#
# `types:` is spelled out although `checks_requested` is the ONLY activity
# type GitHub defines for `merge_group` today — the two spellings are
# equivalent right now (objectstack's `ci.yml` and `lint.yml` use the bare
# `merge_group:` form and produce queue builds normally, 3552 of them). Naming
# the type means a second activity type added later cannot silently start
# queue builds this workflow was never written for.
#
# `concurrency` below needs no merge-queue special case, and that was checked
# rather than assumed: on `merge_group` the `github.event.pull_request` half of
# the group expression is null, so the group falls back to `github.ref`, which
# on a queue build is the queue's own generation — measured on objectstack,
# `gh-readonly-queue/main/pr-6594-251e888ac9ace8226f3a8450951e5b40a0a84c2c`.
# It can collide with neither a pull-request group (a bare PR number) nor a
# push group (`refs/heads/main`), so a queue build and the PR build it came
# from never cancel each other.
merge_group:
types: [checks_requested]
workflow_dispatch:
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true
# `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
# against the merge base, which a depth-1 clone cannot resolve.
fetch-depth: 0
# ── Always report; run only when it matters (objectui#3523) ──────────
# `on.pull_request.paths-ignore` used to skip this whole workflow on a
# docs-only or changeset-only pull request, so the `Lint` context was
# simply absent there — and a required check that never reports leaves the
# PR pending forever (in the merge queue, until the ruleset's 60-minute
# timeout fails it). The filter moved from the trigger into the job: the
# job always runs and always reports, the paths decide only whether the
# expensive steps execute. `ci.yml`'s `docs` job is the in-repo precedent
# for the shape, and its `type-check` job carries the long version of this
# note. The list below IS the `paths-ignore` it replaced; the `push`
# trigger keeps its copy, because nothing judges a push to `main`.
#
# Fails OPEN: if the diff cannot be computed the job runs everything,
# rather than reporting green having linted nothing (objectstack#4928).
- name: Decide whether this change needs a full run
id: relevant
run: |
if [ "${{ github.event_name }}" != 'pull_request' ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
exit 0
fi
if ! CHANGED=$(git diff --name-only \
'${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
. \
':(exclude,glob)**/*.md' \
':(exclude,glob)content/**' \
':(exclude,glob)docs/**' \
':(exclude,glob).changeset/**'); then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
exit 0
fi
if [ -n "$CHANGED" ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo "$CHANGED"
else
echo 'should_run=false' >> "$GITHUB_OUTPUT"
echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
fi
- name: Enable Corepack
if: steps.relevant.outputs.should_run == 'true'
run: corepack enable
- name: Verify pnpm version
if: steps.relevant.outputs.should_run == 'true'
run: pnpm --version
- name: Setup Node.js
if: steps.relevant.outputs.should_run == 'true'
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
# Every package must run ESLint or be declared a known gap. turbo skips
# scriptless packages silently, so without this a package reads as clean
# because nothing linted it. Runs before install: only reads package.json.
- name: Verify lint coverage
if: steps.relevant.outputs.should_run == 'true'
run: node scripts/check-lint-coverage.mjs
# ── One entry guard, one predicate (objectui#6092) ───────────────────
# A `scripts/**` CLI has to answer "did node run me, or did something
# import me?" before it does anything, and the hand-typed answers in this
# tree had drifted into NINE spellings across 28 `.mjs` files. Every one
# of them fails the same way: node resolves symlinks for the module graph
# but leaves `process.argv[1]` as the caller typed it, so a script reached
# through a symlink compares two different paths, answers false, and does
# NOTHING — exit 0, no output. The wrappers that spawn these tools hold
# `result.status` only, so an inert child is a green gate. Measured on a
# real blocking gate in this repository (objectui#6078):
# `check-skills-paths.mjs` run directly on a deliberately broken tree
# exits 1 with 696 bytes naming the dead path; run through a symlink
# against the SAME tree it exits 0 with no output.
#
# `scripts/invoked-as.mjs` landed here in #5984 with a header stating that
# a gate enforced the single-spelling rule. It did not exist, and the
# sweep it described had not happened either (objectui#6078). This is that
# gate. It lands BEFORE the conversion on purpose: the worklist grew while
# the card sat open, so a sweep with nothing under it can be undone
# silently by the next pull request. The 29 existing guards are baselined,
# SHRINK-ONLY, and the script's own header says why that is safe.
#
# Runs before install, next to `check-lint-coverage.mjs` above and for the
# same reason: it reads sources with no dependency beyond node builtins
# and two local modules, so nothing it needs is in `node_modules`. Placed
# before `pnpm install` it also cannot be made green by an install
# failure. `--self-test` runs FIRST and is the half that stops the gate
# rotting into decoration — it drives the scanner over fixture sources,
# including the nine spellings measured in this tree, and pins the
# baseline in every direction it can move.
#
# Invoked as `node` rather than through the `pnpm check:entry-guard` alias
# for the pre-install placement, matching `check-lint-coverage.mjs` above
# and `check-cross-repo-closer-outcome.mjs` below. The alias exists in
# `package.json` for local use and `scripts/__tests__/entry-guard-wiring.test.ts`
# holds the two spellings to the same script.
- name: Verify every scripts/ entry guard goes through one predicate
if: steps.relevant.outputs.should_run == 'true'
run: |
node scripts/check-entry-guard.mjs --self-test
node scripts/check-entry-guard.mjs
- name: Turbo Cache
if: steps.relevant.outputs.should_run == 'true'
uses: actions/cache@v6
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Install dependencies
if: steps.relevant.outputs.should_run == 'true'
run: pnpm install --frozen-lockfile
- name: Run linter
if: steps.relevant.outputs.should_run == 'true'
run: pnpm lint
# ── The cross-repo closer's outcome contract (#5261) ──────────────────
# `cross-repo-issue-closer.yml` carries ~250 lines of inline
# github-script, and until this step existed it was code nobody had ever
# seen run: it fires only on a merge, its conclusion is required by
# nothing, and every one of its runs so far has been green. That last part
# is the problem rather than the reassurance — measured over every merged
# pull request in this repository, its close loop has had a live target
# roughly one and a half times a day since it landed, took the same
# `already closed -- skipping` exit every time, and left no backlink on
# any of them. Greenly. The header of the workflow carries the figures.
#
# This step is the exercise: the shipped script is extracted from the YAML
# with a real parser (never retyped) and run under doubles the way
# actions/github-script runs it, as one AsyncFunction body. The scenarios
# pin the target parse, the target KIND, and the outcome of every exit —
# which of setFailed / warning / job summary fires, and which API calls
# were made.
#
# Assertion 0 is the compile, and it is not theoretical: the framework's
# copy of this workflow was taken down twice in one day by a `SyntaxError`
# in the inline block, i.e. a script that never ran at all, on a
# post-merge workflow whose red nothing else in CI can see.
#
# `--self-test` runs FIRST and is the half that stops the battery rotting
# into decoration: it mutates the shipped script — downgrade the verdict
# to a warning, break out of the loop instead of isolating, drop the
# same-repo skip, collapse the already-closed branch, strip the backlink
# marker, drop the pull-request guard, and the rest — and requires the
# battery to go RED for each, naming the scenario that catches it. Neither
# the mutations nor the scenarios are counted here: a hand-copied
# enumeration drifts by construction, which is the lesson this workflow's
# own header records. A mutation whose anchor no longer exists is a
# failure too, so rewriting the workflow cannot leave them silently
# matching nothing.
#
# Invoked as `node` rather than through a `pnpm check:*` alias, matching
# `check-lint-coverage.mjs` above. No network, no build; well under a
# second.
- name: Cross-repo closer outcome contract
if: steps.relevant.outputs.should_run == 'true'
run: |
node scripts/check-cross-repo-closer-outcome.mjs --self-test
node scripts/check-cross-repo-closer-outcome.mjs
# ── The product's own check command, on the product's own tree ────────
# `objectui check` is what a consumer runs against their schema tree, and
# the root `check` script points that same command at this repository.
# Nothing ran it. It exited 1 with 64 errors on `main` — and had done
# since the first `tsconfig.json` grew a comment — until someone ran it by
# hand while measuring something unrelated (objectui#5237, fixed by
# #5245). A shipped command sitting red on its own repository is the
# dogfood invariant failing, and the reason it could sit there is that no
# gate ever asked. This is that gate (objectui#5246).
#
# The build step below is not a convenience. The root script is
# `node packages/cli/dist/cli.js check`, and this job installs without
# building, so without it the step dies on a missing file. That failure
# mode is worse than no gate at all: it is red for a reason that has
# nothing to do with the tree being checked, and the obvious repair from
# outside is to delete the step — leaving the hole exactly as it was, now
# with a commit saying it was considered.
#
# `...` is pnpm's dependency closure: the CLI plus every workspace
# package it depends on, in topological order, derived from the graph
# rather than listed here. The closure is the requirement and not just
# tidiness — `dist/cli.js` imports `@object-ui/types`' built output at
# startup, so building the CLI package alone produces a binary that
# cannot load (objectui#5237 records the same import as the reproduce
# recipe's first step).
#
# Deliberately NOT `turbo run build`, although the Turbo cache above is
# restored by this point and would usually make it free. A turbo cache
# HIT restores a task's recorded outputs, and an entry recorded with an
# empty output set replays as "cache hit, replaying logs" plus FULL
# TURBO while writing no `dist/` at all — measured on this repo, where
# the CLI then died with ERR_MODULE_NOT_FOUND on the types import above.
# A blocking gate must not be able to fail for a reason that lives in a
# cache rather than in the tree it is judging, which is the same argument
# as the paragraph above one level down. Building through pnpm has no
# cache layer to replay, and it costs well under a minute.
- name: Build the CLI the self-check runs
if: steps.relevant.outputs.should_run == 'true'
run: pnpm --filter '@object-ui/cli...' build
# Errors only, which is the command's existing behaviour rather than a
# setting chosen here: a parse failure increments the error count, a
# non-zero count is the only thing that exits 1, and the unknown-schema-
# type arm prints and moves on. So this step blocks on the same arm
# `pnpm lint` above does, and for the same reason — errors are a signal,
# the warning stream is known debt. Nothing here promotes those warnings
# to failures, and no output-suppressing flag hides them either: they stay
# visible in the log and non-blocking. That arm belongs to objectui#5127,
# which is open; whatever it settles changes what this step PRINTS, never
# what it fails on. No count is quoted, for the reason the `--max-warnings`
# paragraph in the header gives.
- name: Verify the CLI's own check command passes on this repository
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check