Skip to content

feat(sdk): generate and publish @formbricks/hub from this repo (ENG-2092) - #127

Open
xernobyl wants to merge 5 commits into
mainfrom
feat/ENG-2092_hey-api-sdk-pipeline
Open

feat(sdk): generate and publish @formbricks/hub from this repo (ENG-2092)#127
xernobyl wants to merge 5 commits into
mainfrom
feat/ENG-2092_hey-api-sdk-pipeline

Conversation

@xernobyl

Copy link
Copy Markdown
Contributor

What does this PR do?

Replaces the Stainless-generated @formbricks/hub SDK with one generated here from openapi.yaml by @hey-api/openapi-ts, published to npm on a stable Hub release.

Linear: https://linear.app/formbricks/issue/ENG-2092 · Decision doc: Hub SDK after Stainless — why Hey API

⚠️ Do not merge-and-release before the npm prerequisite below is done — the first publish will fail on authentication.

Why. Stainless shuts down 1 September 2026. Of six generators tested against this spec, Hey API was the only free one that cleared every requirement — and Stainless was the only one of the six that got our repeated array filters wrong, which is why formbricks/formbricks carries ~440 lines correcting it.

The SDK is a build artifact. The generator config is committed; the output is not. src/generated/ and dist/ are gitignored, produced in CI, and published on release. That is defensible here for three specific reasons, and stops being defensible if any one stops holding:

  1. the generator is deterministic — verified byte-identical across runs, which is what the skip-if-unchanged check depends on;
  2. npm provenance names the exact source commit, so anyone can regenerate and compare;
  3. the tarball ships readable src/ (verified: 30 files, 144 KB packed).

Publishing is credential-free. npm trusted publishing (OIDC) — no token, and unlike the script this replaces, no token fallback. Provenance is automatic for a public package from a public repo.

Two jobs, on purpose. The build job runs the generator and its transitive dependencies and holds no id-token; a separate publish job downloads only the packed tarball and runs npm publish. Without that split, one compromised dev dependency could publish arbitrary code as @formbricks/hub — to ~10k downloads/month, carrying genuine provenance, which makes it harder to spot rather than easier.

Version starts at 0.13.0 and the SDK version line stays independent of the Hub's (they have never matched — npm was at 0.12.0 while the Hub was at 0.8.5). npm caret ranges pin the minor on 0.x, so ^0.12.0 consumers stay on 0.12.x and are not dragged into the method renames.

Also in here: stainless-action.yml deleted, and the three x-stainless-model extensions removed from openapi.yaml — verified they were doing nothing for us (see the commit for what was checked).

The rename, for consumers

Resource methods become one flat function per operation, so bundlers can tree-shake:

- await client.feedbackRecords.list(params)
+ await listFeedbackRecords({ client, query: params })

createHubClient({ apiKey, baseUrl }) is provided as sugar; baseUrl is required rather than defaulted because the Hub is self-hostable. Zod validators for every shape live behind @formbricks/hub/schemas so the default entry pulls in zero dependencies and zod stays an optional peer.

The full 15-method → 31-function mapping is in the job summary of the sdk-preview workflow, and is what ENG-2351 will migrate against.

Prerequisite: npm trusted publishing must be repointed (needs @mattinannt)

@formbricks/hub@0.12.0 was published via OIDC bound to formbricks/hub-typescript + publish-npm.yml. npm allows one trusted publisher per package, matched on repository and workflow filename, case-sensitive. Until it is repointed, the publish job fails with "Unable to authenticate".

On npmjs.com → @formbricks/hub → Settings → Trusted Publisher:

Field Value
Repository formbricks/hub
Workflow filename publish-sdk.yml
Environment npm-publish
Allowed actions npm publish
  • Environment is not optional in practice. The binding does not include a branch or tag, so without it anyone with push access could publish arbitrary content from any branch with genuine provenance. A GitHub Environment named npm-publish with a deployment-branch rule (tags / default branch) is what closes that. The workflow already references it.
  • Allowed actions must be set explicitly — configurations created after 2026-05-20 require it, and ours is new.
  • Repointing removes hub-typescript's ability to publish @formbricks/hub, so do it at cutover. @formbricks/hub-mcp is unaffected (separate config, token-published).
  • Afterwards, set "Require two-factor authentication and disallow tokens" on the package. It does not affect trusted publishers and closes the residual old-token path.

Maintainers are formbricks-com and matthiasnannt; I don't have access.

How should this be tested?

All of this runs locally from sdks/typescript, with no secrets and no database:

cd sdks/typescript
pnpm install --ignore-scripts
pnpm build          # generate + dual ESM/CJS build
pnpm test           # builds, then runs the wire tests
./node_modules/.bin/tsc --noEmit
  1. 31 operations generated, one per operationId in openapi.yaml, as flat functions with no class. tsc --noEmit reports 0 errors.
  2. The regression test is the point. tests/wire.test.mjs drives the built package (so it covers the exports map) against a local echo server and asserts { source_type: ["survey","review"] } goes out as ?source_type=survey&source_type=review. I confirmed it fails on the old comma-joined output rather than passing regardless — replay ?source_type=survey%2Creview through its assertions and all three reject it.
  3. Determinism: pnpm generate twice and diff -r src/generated — must be byte-identical. Everything else depends on this.
  4. Tarball hygiene: npm pack --dry-run --json — 30 files, nothing outside the files allowlist (dist, src, README.md, LICENSE), and readable src/ present.
  5. Spec cleanup didn't regress anything: after removing the x-stainless-model extensions, still 31 operations, children?: Array<TaxonomyNodeData> intact, TaxonomyRunData and EnrichmentTypeStatus still single shared types. npx @stoplight/spectral-cli@6 lint openapi.yaml → no errors.
  6. The pipeline, without publishing: run publish-sdk.yml via workflow_dispatch with dry_run: true. The build job should run end to end and the publish job should be skipped.
  7. The skip path: with the spec unchanged from 0.12.0's, the compare step should report "identical — skipping publish" rather than publishing.
  8. The guard path: set version back to 0.12.0 with a changed surface — the build must fail with "version 0.12.0 is already on npm. Bump the version".
  9. sdk-preview on this PR should print the surface diff to its job summary and request no write permissions.

Not testable before the npm change: the real publish. Treat the 0.9.0 Hub release as the cutover and watch the job.

Checklist

Required

  • Filled out the "How to test" section in this PR
  • Read Repository Guidelines
  • Self-reviewed my own code
  • Commented on my code in hard-to-understand bits
  • Ran make build — no Go code changed
  • Ran make tests — not applicable; the SDK has its own suite (pnpm test), which passes 3/3
  • Ran make fmt and make lint; no new warnings — no Go changes; Prettier run over the new TS/JSON/MD
  • Removed debug prints / temporary logging
  • Merged the latest changes from main onto my branch — cut from origin/main, 0 behind
  • If database schema changed — no schema change

Appreciated

  • If API changed: added or updated OpenAPI spec and ran contract tests — openapi.yaml changed only by removing vendor extensions; Spectral clean, no behaviour change
  • If API behavior changed — none
  • Updated docs in docs/ if changes were necessary — AGENTS.md gains a TypeScript SDK section; the npm-facing README is sdks/typescript/README.md
  • Ran make tests-coverage — no Go logic changed

Note on conflicts: #126 (ENG-2369, docs off Stainless) also edits AGENTS.md. I placed this section higher in the file to avoid a conflict, but whichever lands second may still need a trivial rebase.

Replaces the Stainless-generated SDK, whose platform shuts down on 1 Sep 2026.
See ENG-2092 for why Hey API: of six generators tested against this spec, it was
the only free one that cleared every requirement, and Stainless was the only one
that got our repeated array filters wrong.

The SDK is a build artifact: the generator config is committed, its output is
not. src/generated/ and dist/ are gitignored, produced in CI, and published to
npm on release. That is defensible here because the generator is deterministic
(verified byte-identical across runs), npm provenance names the source commit so
anyone can regenerate and compare, and the tarball ships readable src/.

- 31 flat functions, one per operationId in the spec — no class, so consumers can
  tree-shake. tsc --strict clean, dual ESM/CJS with .d.ts for both.
- Zero runtime dependencies. The zod validators live behind a separate
  "@formbricks/hub/schemas" entry so importing the client pulls nothing in, and
  zod stays an optional peer.
- createHubClient({ apiKey, baseUrl }) as sugar over createClient(createConfig()).
  baseUrl is required, not defaulted: the Hub is self-hostable.
- files is an allowlist, not Stainless's "**/*" — generation and packing happen in
  a directory that can hold .env and build scratch. Verified: 30 files, nothing
  outside dist/src/README/LICENSE.
- Generator version pinned exactly, so a bump is a reviewable change.

tests/wire.test.mjs pins the regression that motivated the migration: array
filters must go out as repeated parameters, never comma-joined. It runs against
the built package rather than src/, so it also covers the exports map. Confirmed
it fails on the old comma-joined output rather than passing regardless.

Version starts at 0.13.0. npm caret ranges pin the minor on 0.x, so `^0.12.0`
consumers stay on 0.12.x and are not dragged into the method renames.

Refs ENG-2092
…on PRs

Two workflows, deliberately separate files.

publish-sdk.yml runs on a stable Hub release (prereleases skipped, matching the
Docker builds) and is split into two jobs so the publish credential never
coexists with third-party code execution:

- build: installs with --ignore-scripts, generates, type-checks, runs the wire
  tests, packs, and compares against the published tarball. Holds no id-token.
- publish: downloads only that tarball and runs npm publish. No checkout, no
  install, nothing from the repo or its dependencies executes here.

That split matters because the build job runs the generator and its transitive
dependencies; a single compromised dev dependency in a job holding id-token could
publish arbitrary code as @formbricks/hub with genuine provenance.

The compare step is the artifact model's core, not a nicety: with nothing
committed, the published tarball is the only baseline. Identical output skips the
publish so a Go-only release does not burn a version; changed output against an
already-published version fails loudly rather than overwriting.

Auth is npm trusted publishing (OIDC) — no token, and no token fallback, unlike
the script this replaces. Provenance is automatic for a public package from a
public repo. The publish job runs in an npm-publish environment because npm's
trusted-publisher binding covers repo and workflow filename but NOT the ref, so
the environment's branch protection is what stops any branch publishing.

sdk-preview.yml diffs the exported surface against npm on PRs touching the spec
or sdks/**. It generates PR-controlled code, so it is pull_request (never
pull_request_target), read-only, secret-free, and writes to the job summary
rather than a PR comment — no write token anywhere. Commenting would need the
artifact + workflow_run pattern, and a careless version of that trips Scorecard's
Dangerous-Workflow check, so the summary is the default.

No path-filter changes needed: sdks/** matches none of the Go workflows' patterns
(and sdks/typescript/tests/ does not match their root-anchored tests/**).

Refs ENG-2092
- Deletes .github/workflows/stainless-action.yml. It built the SDK on the
  Stainless platform on every PR and merge; that platform shuts down on 1 Sep
  2026 and publish-sdk.yml replaces it.
- Removes the three x-stainless-model extensions from openapi.yaml along with the
  vendor-specific comments explaining them. Verified they were doing nothing for
  us: after removal the generated client still has all 31 operations, the
  recursive TaxonomyNodeData.children survives, and TaxonomyRunData and
  EnrichmentTypeStatus are still single shared types — named from the spec's own
  component names, which is what the extensions were overriding. Spectral clean.

Refs ENG-2092
#126 (ENG-2369) appends its docs bullets after the same two lines, so both PRs
adding there conflicted regardless of the sections being far apart. Anchors them
against different neighbours so the two land in either order without a conflict.

Refs ENG-2092
… tarball

Two problems found reviewing my own workflow.

The prerelease guard was a step that ran `exit 1`, which marks every prerelease
as a FAILED workflow run rather than skipping it. hub-release.yml sets an output
and gates downstream jobs instead; this now gates the job itself, so a prerelease
is genuinely skipped.

The publish job took a pre-packed .tgz. npm's own docs are inconsistent about
whether provenance is generated when publishing a tarball rather than a package
directory — the trusted-publishing page says no flag is needed, the provenance
page says to pass --provenance and describes the directory flow. A release is not
the moment to discover which is right, so the build job now hands over the
publishable directory (dist, src, package.json, README, LICENSE) and the publish
job runs `npm publish` from it, which is the documented path. --provenance is
passed explicitly for the same reason: harmless if already implied, silent if not.

Smoke-tested locally: npm publish --dry-run from a reconstructed artifact
directory produces the same 30-file, 148 kB tarball as packing in place. The
compare step's three branches were exercised against the real registry —
differs+new version publishes, differs+published version exits 1, and identical
input skips.

Refs ENG-2092
@xernobyl
xernobyl marked this pull request as ready for review August 26, 2026 10:02
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 15 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: da2b9df8-5b5c-490c-b49b-8bf7e79a4a9e

📥 Commits

Reviewing files that changed from the base of the PR and between 0dd2db1 and 82bbb6e.

⛔ Files ignored due to path filters (1)
  • sdks/typescript/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • .github/workflows/publish-sdk.yml
  • .github/workflows/sdk-preview.yml
  • .github/workflows/stainless-action.yml
  • AGENTS.md
  • openapi.yaml
  • sdks/typescript/.gitignore
  • sdks/typescript/LICENSE
  • sdks/typescript/README.md
  • sdks/typescript/openapi-ts.config.ts
  • sdks/typescript/package.json
  • sdks/typescript/src/index.ts
  • sdks/typescript/src/schemas.ts
  • sdks/typescript/tests/wire.test.mjs
  • sdks/typescript/tsconfig.json
  • sdks/typescript/tsdown.config.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@xernobyl

Copy link
Copy Markdown
Contributor Author

Marked ready so CodeRabbit actually reviews it — it skips drafts, so its green tick until now meant "didn't look", not "looks fine".

Please don't merge this before the npm trusted-publisher change lands. Nothing here breaks on merge, but the first Hub release after it would try to publish and fail authentication, which is a confusing way to find out. The four values needed are in the description; @mattinannt is doing it since maintainers are formbricks-com and matthiasnannt.

Ordering that works: npm repointed → this merges → next Hub release publishes @formbricks/hub@0.13.0.

@xernobyl

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant