Skip to content

feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) #11264

feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278)

feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) #11264

Workflow file for this run

name: Lint & Type Check
on:
push:
branches:
- main
pull_request:
branches:
- main
# Same policy as ci.yml: superseded runs on the same PR/branch waste runners
# and delay feedback; cancel them. Push runs to main group by commit ref, so an
# in-flight main run is cancelled only by a newer main push.
concurrency:
group: lint-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Enforces the no-restricted-imports guard against @objectstack/spec root
# namespace imports (the dormant rule was never run in CI). Syntactic
# only, so no build step needed.
- name: ESLint
run: pnpm lint
# Raw NUL guard (#3127): one literal U+0000 byte makes grep/ripgrep treat
# the whole file as binary and silently return ZERO matches — the file drops
# out of code search and out of every grep-based lint, with no error saying
# so. Nothing else catches it: git sniffs only the first 8000 bytes to decide
# binary-ness, and protocol.ts carried its NUL at offset 147230, so it kept
# diffing as ordinary text through review. That blind spot let six files
# accumulate the same defect. Authors must write the unicode escape instead.
- name: Raw NUL byte guard
run: pnpm check:nul-bytes
# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
# Markdown/MDX are not type-checked or ESLinted, so skills/ and
# content/docs/ can drift back to teaching the bare `: Page = {}` literal
# while the examples (which ARE linted) stay clean. This fails on any bare
# metadata literal for the 16 factory domains in a doc code block.
- name: Doc/skill authoring guard
run: pnpm check:doc-authoring
# ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs
# and skills. Existing occurrences are frozen in the baseline (better-auth
# boundary, ARIA samples, educational mentions); NEW occurrences fail.
# Improvements ratchet the baseline down via --update.
- name: Reserved-word ("role") docs ratchet
run: pnpm check:role-word
# #3280/#3290 org-identifier guard: `organizationId` is the blessed
# developer-facing name for the caller's active org in hook/action bodies;
# the `session.tenantId` alias was REMOVED in v11 (#3290). Keeps our own
# reference code (examples/, apps/, AND packages/) — which authors and AIs
# copy from — off the removed name. Hard-fail (surfaces carry zero
# occurrences today); tests, comments, skills/ and docs/ are excluded, and
# driver-layer `execCtx.tenantId` is never matched.
- name: Org-identifier authoring guard
run: pnpm check:org-identifier
# Authorization resolution must stay single-sourced (resolveAuthzContext,
# @objectstack/core). Guards against a duplicate resolver copy drifting on a
# security path (the REST-vs-dispatcher sys_user_role drift) and against an
# entry point silently dropping the delegation.
- name: Single authz resolver guard
run: pnpm check:authz-resolver
# #4093 follow-up. Discovery tells a consumer an absent capability is
# absent AND what to install. The first half has been carefully honest
# since #2462/#4000; the second was invented from the slot name, so ten
# of fifteen entries named packages that do not exist — a dead end handed
# to whoever is trying to fix their stack. Asserts every package named in
# CORE_SERVICE_PROVIDER is a real workspace package, and that no core
# slot is missing an entry.
- name: Service-provider remedy guard
run: pnpm check:service-providers
# #3843 response-envelope guard. The route ledgers audit which routes EXIST;
# nothing audited what comes back, so six route modules emitted bodies
# outside BaseResponseSchema while carrying green `sdk` rows. Counts the
# response write sites per module via the TS AST: when every body goes
# through the module's sendOk/sendError pair that count is fixed, so a NEW
# route hand-rolling a body fails here — coverage a driven test cannot give.
#
# Repo-wide on purpose. The three predecessors were per-package, which is
# structurally unable to notice a module nobody thought to convert; both
# ratcheted modules in the script's table were found by this scan and are
# absent from #3843's hand survey. An undeclared route module is an error,
# never a default. Runs its own --self-test first.
- name: Response-envelope guard
run: pnpm check:route-envelope
# Error-code casing guard (ADR-0112, #4003). The ledger's admission test
# enforces casing on every code someone REGISTERS; this catches the ones
# nobody registers — an unregistered lowercase literal in a code position
# is invisible to both the ledger and to the schema on any route that does
# not parse its own response. That is how 208 of them accumulated across
# 10 packages before batch 2. Runs its own --self-test first.
- name: Error-code casing guard
run: pnpm check:error-code-casing
# Namespace-wildcard fall-through guard (#4116). A handler mounted on
# `<prefix>/*` claims the whole namespace, and Hono's first-registered
# handler that answers wins — so a TERMINAL wildcard makes every other
# route under that prefix reachable only by registration luck. That shape
# has now cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every
# one was found by hand, never by CI. Per-plugin tests cannot cover the
# next one; what was missing is the ENUMERATION. Three states — yields
# (verified from the AST, so it cannot rot) / exempt-with-reason / ratchet
# — and an undeclared mount is an error, never a default. Runs its own
# --self-test first.
- name: Wildcard fall-through guard
run: pnpm check:wildcard-fallthrough
# Release-notes drift guard: the platform is one version-locked train, so
# every released @objectstack/spec major must have a curated, navigable
# release page at content/docs/releases/v<major>.mdx. Catches the gap that
# let v10–v14 ship with no page while spec was already at 14.x.
- name: Release-notes drift guard
run: pnpm check:release-notes
# #3825 Node-version drift guard: a runtime pin is 18 separate string
# literals across .github/workflows, so a split is invisible until someone
# greps for it. One did open — every PR gate sat on Node 20 (EOL
# 2026-04-30) while release.yml and publish-smoke.yml ran 22, so code was
# verified on one runtime and shipped from another. Nobody chose that; one
# workflow got bumped to clear one error and the rest stayed behind. It
# surfaced only when a dependency needing >=22 aborted the vitest worker
# at the process level, which vitest reported as a PASSING suite with 17
# cases silently skipped (#3812). .nvmrc is now the single source of
# truth, and this holds every workflow to it.
- name: Node-version drift guard
run: pnpm check:node-version
# #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the
# whole package directory, and 20 of the 49 publishable packages declared
# none — so consumers installed TypeScript sources, unit tests and build
# tooling, with dist/ landing on top of them rather than instead of them
# (@objectstack/plugin-webhooks: 21 files, three of them unit tests). The
# other 29 did declare it, so this was a hand-copied line with no gate —
# the #3786 shape, where whoever forgets it gets no signal at all. Also
# checks the whitelist is SUFFICIENT (covers every entry point, so
# tightening one cannot ship a package that fails to resolve) and MINIMAL
# (admits no test or build script), which keeps #4206's "`<pkg>/scripts/**`
# is never runtime code" assumption continuously verified instead of
# hand-checked. Runs its own --self-test first: the pattern semantics can
# be wrong while every package is right.
- name: Published-files whitelist guard
run: pnpm check:published-files
typecheck:
name: TypeScript Type Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# This job runs the full workspace build below ("Build workspace
# packages"); without a restored turbo cache that step rebuilt every
# package from scratch on every run (~4½ min) while ci.yml's jobs — which
# do carry this cache — finished the same build in under a minute. Same
# key scheme as ci.yml so the fallback prefix can also hit main's caches.
# Restore-only on PRs (same policy as ci.yml): PR-side saves churned the
# 10 GB Actions cache pool and evicted the main seeds; only main pushes
# save (the "Save Turbo cache" step at the end of the job).
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check (@objectstack/spec)
run: pnpm --filter @objectstack/spec exec tsc --noEmit
# Meta-gate (#4203): the `check:generated` aggregate reconciles its
# GATED/NO_GENERATOR ledger against packages/spec/package.json on every run,
# in both directions, so a `check:`/`gen:` script nobody classified fails
# instead of quietly dropping out of coverage. But the reconciliation only
# ran where the aggregate ran — locally. CI runs the gates as individual
# steps, so a PR adding an unclassified script kept every CI gate green
# while the wrapper AGENTS.md prescribes exited red on `main`, running zero
# gates. Twice in three days: #4177 (`check:variant-docs`, fixed only when
# #4194 collided with the same wall) and #4232 (`check:strictness-ledger`,
# caught while wiring this step). It could not live in ci.yml's
# `check-generated` job — that job was gated on a `generated` paths filter
# that never watched packages/spec/package.json, the one file every
# offending PR must touch. That job is gone now (see the four gates below);
# this job is unfiltered and required, so the meta-gate cannot go dormant.
# Reads package.json only; no build, sub-second.
- name: "Check every check:/gen: script is classified in the check:generated ledger"
run: pnpm --filter @objectstack/spec check:generated --reconcile-only
# The last four artifact gates, moved here from ci.yml's `check-generated`
# job when that job and its `generated` paths filter were deleted (#4291).
#
# The filter was a hand-maintained duplicate of each gate's input set, and
# nothing reconciled the two. It drifted three times on record, each found
# by accident and written up in a comment rather than gated: #2584 moved a
# generated page and the filter kept watching the old path, so hand-edits
# went unchecked for months; #3855 listed specific spec paths but no schema
# dirs, so `check:authorable-surface` went dormant on exactly the PRs that
# remove an authorable key; and `json-schema.manifest.json` — the #2978
# ratchet that is the ONLY durable record of every emitted schema, since
# json-schema/ is gitignored — was never watched at all, so a PR retiring a
# key from it skipped its own verifier. Six gates had already escaped to
# this job one at a time, each with a comment saying the filter had failed
# it; these four finish the migration and retire the duplicate ledger.
#
# Affordable because the work was already being done here: `check:docs`
# below runs `gen:schema` — the same scripts/build-schemas.ts that backs
# `check:authorable-surface` — and that whole step measures 4s in CI. All
# four read source via tsx and need no build, so they run before the
# workspace build, as the gates below already do.
- name: Check skill docs are generated from SKILL.md frontmatter
run: pnpm --filter @objectstack/spec check:skill-docs
- name: Check spec-changes.json is regenerated with the ADR-0087 registries
run: pnpm --filter @objectstack/spec check:spec-changes
- name: Check the protocol upgrade guide is regenerated with the ADR-0087 registries
run: pnpm --filter @objectstack/spec check:upgrade-guide
# The authorable KEY surface — what a metadata author may write, which for
# this platform is the third-party API. `api-surface.json` records exported
# names and `api-surface-signatures.json` hashes factory types as TypeScript
# PRINTS them (a reference, never structurally expanded), so neither sees a
# key added to or removed from a schema. #3883 removed three authorable keys
# with every witness green; #3733 did it by accident. ADR-0059 §5 deferred
# this gate until a narrowing actually slipped both — it has.
- name: Check the authorable key surface is recorded and nothing vanished
run: pnpm --filter @objectstack/spec check:authorable-surface
# Generated-docs gate: content/docs/references/** is generated from the spec
# by `gen:schema && gen:docs` and committed. Nothing regenerated it in CI, so
# it drifted silently — #3076 added RowCrudActionOverride to the spec and the
# public reference docs never learned the type existed. Regenerates and fails
# on any difference.
#
# Deliberately lives in this job, not in ci.yml's "Build Docs": that job is
# gated on a `docs` paths-filter that does not include packages/spec/**, so it
# skips exactly the spec-only PRs that cause this drift (#3076 was one). This
# job has no paths filter and is a required status check, so the gate cannot go
# dormant. It reads src/ + json-schema/ via tsx and needs no build, so it runs
# before the workspace build and fails in ~2s.
- name: Check generated reference docs are in sync with the spec
run: pnpm --filter @objectstack/spec check:docs
# Same class, same reasoning, different surface: skills/*/references/_index.md
# and the objectstack-ui react-blocks contract are generated from
# packages/spec/src and committed, and nothing regenerated them either. These
# ship to third parties via `npx skills add objectstack-ai/objectstack`, so the
# drift is served straight to consumers' agents — 6 of 113 schema pointers named
# files the spec had already deleted or renamed.
#
# These moved here early, when ci.yml still had a filter-gated
# `check-generated` job: its `generated` filter listed specific spec paths
# (migrations/, conversions/, protocol-version) but no schema dirs, so a PR
# touching src/data/** or src/ui/** — exactly what drives these two
# artifacts — never triggered it, and it was not required either. That job
# and its filter are gone (#4291); every artifact gate now lives here.
#
# Both read packages/spec/src via tsx and need no build (verified with every
# workspace dist/ removed), so they run before the workspace build. check:skill-refs
# additionally fails on a SKILL_MAP entry naming a file the spec no longer has:
# that silent skip is what let the map keep pointing at data/dataset.zod.ts for a
# year after #1620 renamed it to data/seed.zod.ts.
- name: Check generated skill references are in sync with the spec
run: pnpm --filter @objectstack/spec check:skill-refs
- name: Check the react-blocks contract is in sync with the spec
run: pnpm --filter @objectstack/spec check:react-blocks
# Example apps are AI-authoring reference templates; a red typecheck is a
# bad signal to copy from. tsup transpiles them without a full typecheck,
# so build alone will not catch type drift — typecheck them explicitly.
# They import from built workspace packages, so the packages must be built
# first for cross-package type resolution to succeed. The examples'
# dependency closure is requested EXPLICITLY (`./examples/*^...` = deps
# of the examples, not the examples themselves): the bare `./packages/*`
# glob only matches direct children, and the connector packages the
# showcase imports were previously built only by accident — through
# dogfood's dependency chain, which broke when dogfood moved to
# packages/qa/ (#3037).
- name: Build workspace packages
run: pnpm exec turbo run build --filter='./packages/*' --filter='./examples/*^...'
- name: Type check example apps
run: pnpm --filter './examples/*' run typecheck
# Backward-compatibility gate: a frozen third-party-style consumer
# (#2035). Unlike the examples it must NOT be migrated to accommodate a
# spec change — a red typecheck here means the spec dropped/narrowed an
# export a published-spec third party already uses. See the package README.
- name: Type check downstream consumer contract
run: pnpm --filter @objectstack/downstream-contract run typecheck
# Public API-surface gate (#2035): the spec package IS the third-party API.
# A removed/renamed export silently breaks every consumer pinned to a
# published release. This diffs the built export surface against the
# committed snapshot; intentional changes regenerate it via
# `pnpm --filter @objectstack/spec gen:api-surface`. Runs after the build
# step above (reads the built dist).
- name: Check @objectstack/spec public API surface
run: pnpm --filter @objectstack/spec run check:api-surface
# Same surface, the other axis: api-surface.json records that an export
# EXISTS, never what it resolves to — so four exported types sat at `any`
# across a whole major with every gate green (#4171). #4115 tells consumers
# to replace a local declaration with the spec import, which for those four
# traded a precise type for one that constrains nothing, silently: `any` is
# mutually assignable with everything, so the check that would catch the
# swap reports "identical, safe to re-export". Reads the built dist a
# consumer's import actually resolves to, so it runs after the build step
# with the other consumer gates. Self-tests first — a scan whose green
# result is "nothing found" has to prove it can still find something.
- name: Check no exported spec type resolves to `any`
run: pnpm --filter @objectstack/spec run check:exported-any
# Anti-drift for the skill EXAMPLES, not just the skill reference indexes
# (#3094). The TypeScript in skills/ is the first thing an AI copies when
# authoring metadata, yet nothing type-checked it — so it rotted silently
# (`ObjectSchema`/`Data`/`Field` imported from the wrong entry point, a
# `defineStack` key that no longer exists). Each block tagged with an
# `<!-- os:check -->` comment is extracted and run through `tsc --noEmit`
# against the built `@objectstack/spec` declarations — the exact surface a
# consumer's import resolves to — so a renamed export or tightened union
# fails here instead of in a third party's editor. Reads the built dist,
# so it runs after the build step alongside the other consumer gates.
- name: Check skills TypeScript examples compile
run: pnpm --filter @objectstack/spec run check:skill-examples
# Same anti-drift class as the gates above, for the generated translation
# bundles in packages/platform-objects/src/apps/translations/. Nothing
# regenerated them either, so they rotted three ways at once (#3670):
# translations left behind for keys the schema had REMOVED (`enable.trash`
# / `enable.mru`, #2377; agent `visibility`, #1901), keys the schema had
# GAINED with no entry at all (`summaryOperations.*`, the ADR-0105 D8
# invitation-placement fields, and the better-auth 1.7 columns from
# #3647), and `sys_migration` sitting on EMPTY STRINGS in ja-JP/es-ES —
# which renders blank rather than falling back to anything readable.
#
# Every one of those was found by a human happening to re-run the
# extractor. This turns the next one into a red build. Runs in merge
# mode, so it never asks anyone to re-translate: a fresh extract of an
# up-to-date bundle is byte-identical to what is committed.
#
# Now covers ALL NINE packages that own a bundle, not just
# platform-objects. The other eight shipped an `i18n-extract.config.ts`
# that nothing ever ran, and four of them had already drifted — the same
# rot this gate exists to catch, one directory over. Each package is
# checked with the exact command its own config docstring documents, so
# the docs and the gate cannot diverge.
#
# Reads the built @objectstack/spec dist through the extract configs, so
# it belongs after the build step with the other consumer gates.
- name: Check generated translation bundles are in sync with the schema
run: pnpm check:i18n
# Ratchet on the OTHER i18n question. The step above asks "are the
# generated bundles still what the schema produces?"; this one asks "did
# anyone declare a new label and not translate it?" — the gap #3370 closed
# in `os lint`, which had ~1000 pre-existing misses across the examples and
# platform-objects, so `--i18n-strict` cannot simply be switched on without
# painting CI red and getting switched back off. The debt is frozen in
# scripts/i18n-coverage-baseline.json; growth fails the build.
#
# Eight of the twelve tracked configs sit at zero, so for those this is
# already the strict gate — any regression is immediately red.
#
# Runs the built CLI over each config, so it also belongs after the build.
- name: Check no new untranslated declared labels
run: pnpm check:i18n-coverage
# Seed the shared Turbo cache from main only (see the restore step above).
- name: Save Turbo cache (main only)
if: always() && github.event_name == 'push'
uses: actions/cache/save@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}