ci: add built-artifact release gate (#765, #749) - #766
Conversation
Two dist-level regressions shipped as patch releases with nothing in the release flow comparing the built artifact: the 4.2.4 ObservableStatus type break (#749) and the 4.2.4/4.2.5 App Router / Vite crash caused by the CJS use-sync-external-store shim being inlined into the ESM output (#759, fixed in #760). In both cases the source was fine and only the emitted artifact regressed. Adds scripts/release-gate.mjs, run in CI as a new "Verify built artifact" job between build and publish. It verifies the packed reactfire.tgz that the publish job uploads verbatim, so it checks the exact bytes that ship, and it is dependency-free so the job needs no npm ci. Checks (#765 items 1, 2, 4, 5 plus #749): - no-cjs-in-esm: no CJS interop or dynamic require in the ESM entry - externals: externals stay external, nothing unexpected is inlined - exports-map: every path in exports/main/module/typings is in the tarball - types: emitted .d.ts match a checked-in accepted baseline - size: packed tarball and entry-point gzip within 10% of baseline Verified against the published tarballs: 4.2.5 fails no-cjs-in-esm and externals, 4.2.4 fails those plus types (surfacing the ObservableStatus union as a readable diff), and the current build passes. #749 asks for enforcement rather than a warning, but a textual diff cannot classify additive versus non-additive on its own. So the gate fails on any delta and the acknowledgement is `npm run gate:accept` plus a committed baseline, which puts the semver decision in front of a reviewer as a diff. Also repairs the size-limit config, which pointed at TSDX-era filenames (dist/reactfire.esm.js) that the vite build has not emitted for some time, so `npm run size` was silently checking nothing. Items 3 and 7 of #765 (entry-point load test, runtime smoke render) are not included; see release-gate/README.md.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
Follow-up to review feedback on the release gate. Three fixes, all cases
where the gate quietly under-delivered.
1. Add test/release-gate.test.mjs (21 tests, no emulators needed).
The gate had no tests, which is the exact failure mode it exists to prevent:
edit a marker regex, break detection, and every run still goes green. A
silently disarmed gate is worse than no gate because it manufactures
confidence. Fixtures are byte-faithful to what shipped, including the 4.2.5
require shim, and cover the checks end to end against synthetic packages.
Required a small refactor for testability: checks now take a report object
instead of mutating module-level arrays, helpers are exported, and main()
only runs when the script is invoked directly.
2. Fix collectImports missing every minified import form.
The patterns required whitespace after the keyword, so `import{a}from"react"`,
`export{a as b}from"react"` and `export*from"rxjs"` all returned nothing. It
worked only because rolldown happens to emit spaced import statements while
mangling identifiers, which is an incidental output detail rather than a
contract. That is the same class of build-output change that caused #759, so
the gate's own parser was vulnerable to what it exists to detect. Partial
failure was the dangerous case: full failure trips MUST_BE_EXTERNAL loudly,
but a partial format change silently weakens the unexpected-externals half.
3. Make listTypeFiles recursive.
tsconfig emits with rootDir ./src, so a subdirectory of src/ emits nested
declarations. The flat readdir meant dist/nextjs/*.d.ts would sit entirely
outside the #749 check the moment #739 lands src/nextjs.
Also adds an eslint override so plain .mjs is linted as Node with vitest
globals (typescript-eslint disables no-undef for .ts, but not .mjs), and
extends the lint and format globs to cover scripts/.
An independent review found the gate claimed broader coverage than it had. All five are fixed, and every fix is pinned by a test that fails when the fix is reverted (verified by mutating each one back). 1. exports-map never checked `main` or `typings`. It filtered to paths starting with ".", but `main` is "dist/index.umd.cjs" and `typings` is "dist/index.d.ts", neither of which has the prefix. So the two fields its own failure message named were the two it skipped. Probed directly: with both missing and no exports map, it reported zero failures. It passed until now only because exports['.'].require duplicates `main`; `typings` has no equivalent and was genuinely unchecked. The test that appeared to cover this deleted the one path that was covered, so it passed for the wrong reason. 2. The size tolerance was too loose to catch what it advertised. Measured from the published tarballs, the #759 shim inlining moved the ESM entry +3.1% gzip and the tarball +0.3%, both well inside the old ±10% band. The claim that a size guard "might well have caught the shim getting inlined" was wrong, and is corrected in the README: size is a coarse guard against gross packaging changes, not an inlining detector. Tolerance is now ±2%, above CI's version-stamp churn (+0.5% tarball) and below a #759-sized change. The check also had zero test coverage; deleting it outright left every test green. It is now covered, including at the #759 magnitude. 3. Added and removed .d.ts detection was unpinned. The logic was correct but nothing held it there. This is the case the recursion fix in the previous commit exists to feed: a new entry point emitting dist/nextjs/*.d.ts is exactly #739, and a disappearing declaration file is a hard breaking change. 4. Both bundle checks only ever opened the `module` entry. Nothing guarantees a single ESM chunk. A second entry point or rollup deciding to split would put code where an inlined require is invisible, and chunking is the kind of incidental build-output change this gate exists to be robust against. Both checks now walk every ESM file in the package, and expected externals are matched across their union so an import from a chunk still counts. 5. `react` was matched as an exact string, and --accept took a tarball. Switching to the automatic JSX runtime would import react/jsx-runtime and double false positive: unexpected external, plus "react was inlined" when it was not. Now matched as a pattern, like firebase already was. Separately, `--accept <tarball>` would have recorded a baseline from an arbitrary old build, blessing whatever regression it contained; it is now rejected, as are unknown flags.
The zizmor security scan reports 15 `unpinned-uses` findings against this file: actions referenced by floating tag (`actions/checkout@v4`) rather than pinned to a commit hash, which the blanket policy requires. A floating tag can be repointed at new code by whoever controls it, so an unpinned action is an unreviewed dependency with access to the job. Only 3 of the 15 are lines this branch added; the other 12 predate it. The scan runs only when a workflow file changes, and this is the first PR to touch .github/workflows/, so the condition was pre-existing and simply never surfaced before. Fixing only the 3 new ones would leave the check red, since it fails on any Medium/High finding. Each SHA is the current tip of that action's v4 tag, with the resolved release in a trailing comment so the version stays legible and Dependabot can still bump them. docs.yaml has the same two unpinned refs and is not touched here: the scan did not flag it, and this PR is already carrying a release gate. Worth a separate pass.
Clears the two remaining zizmor medium findings on test.yaml. excessive-permissions (6): the workflow declared no `permissions:` block, so every job ran with the default token scope. Added a top-level `permissions: contents: read`. No job writes to the repo through GITHUB_TOKEN; the publish job authenticates to npm with NODE_AUTH_TOKEN, so this does not touch the publish path's credentials. artipacked (4): actions/checkout persists the token into .git/config by default, leaving it readable by every later step in the job. Nothing here re-uses git credentials after checkout, so `persist-credentials: false` is safe. The publish job has no checkout step and is unchanged. Both classes predate this branch. Verified with zizmor 1.25.2 locally: test.yaml goes from 10 medium to 0. The 6 remaining cache-poisoning findings are pre-existing, are already suppressed by the CI config (the failing run exited 13/Medium, not 14/High), and fixing them would mean restructuring how the workflow caches around the publish path, which does not belong in this PR. docs.yaml has findings of its own and is untouched here; the scan only runs on changed workflow files. Worth a separate pass.
|
@jhuleatt this is ready for review. All checks green. Summary of what needs you, roughly in order of how much it matters: 1. One design decision, and it is the reason I have not merged this. #749 asks for the candidate's types to be diffed against the last published version on npm. This PR instead diffs against a baseline committed in the repo, updated deliberately via My lean is the npm-published version, but I did not want to change it unilaterally, because merging as-is auto-closes #749. If we go the npm route I would rather downgrade that to a plain reference and keep the issue open. Happy either way, I just want the call to be yours since it is your issue. 2. Two things needing admin, which I do not have.
3. Heads-up on something this PR uncovered, no action needed from you here. This is the first PR to touch 4. Also correcting myself. An earlier revision of this description claimed a working size guard would likely have caught the #759 shim inlining. That was wrong. Measured against the published tarballs, that regression moved the ESM entry only +3.1% gzip and the tarball +0.3%, so the ±10% band I originally used would have sailed straight past it. Tolerance is now ±2% and the description says plainly that Scope note: this implements #765 items 1, 2, 4, 5 plus #749. Items 3 and 7 (entry-point load test, runtime smoke render) are not included and are documented in Separately, #740 is still waiting on your semver call whenever you get a chance. |
Summary
Adds a built-artifact release gate: a new Verify built artifact CI job between
buildandpublishthat checks the packedreactfire.tgzbefore it can be published.Two dist-level regressions have shipped as patch releases with nothing in the release flow comparing the built artifact:
ObservableStatus<T>from a flat interface into a discriminated union. Type-only, but it broke strict-TS consumers on a patch bump (Add a published .d.ts diff to the release process to catch breaking type changes #749).use-sync-external-store/shiminto the ESM output, producing a dynamicrequire()that threw in any browser bundle (App Router crash: reactfire 4.2.4/4.2.5 throw "dynamic usage of require is not supported" on the client #759, fixed by fix: externalize useSyncExternalStore shim to fix App Router crash #760).In both cases the source was fine and only the emitted artifact regressed.
The job verifies the same tarball the
publishjob uploads verbatim, so it checks the exact bytes that ship. It is dependency-free by design (node builtins plustar), so it needs nonpm ciand cannot itself be broken by a dependency change.Implements #765 items 1, 2, 4, 5 plus #749.
Decision needed before merge
#749 specifies packing the last published version and diffing against that. This PR instead diffs against a baseline committed in the repo (
release-gate/baseline/), updated deliberately vianpm run gate:accept.My lean is diffing against npm, since it cannot drift and is closer to the issue's intent. I have not changed it unilaterally because merging as-is auto-closes #749 — if we go the npm route, that should become a plain reference and the issue should stay open.
Checks
no-cjs-in-esmexternalsexports-mapexports/main/module/typingsmissing from the tarball.types.d.tsversus the accepted baseline, including added and removed declaration files.sizeOn
sizespecifically: it is a coarse guard against gross packaging changes, not an inlining detector, and should not be read as one. Measured from the published tarballs, the #759 inlining moved the ESM entry only +3.1% gzip (16815 to 17330 B) and the tarball +0.3%. The ±2% band is set above CI's version-stamp churn (+0.5% tarball, +0.2% entries) and below a #759-sized change.no-cjs-in-esmandexternalsare what actually catch inlining.Verification
Run against the published tarballs, not just the current build:
no-cjs-in-esmexternalstypes* 4.2.3 predates the shim import entirely, so that is expected rather than a false positive.
The 4.2.4
typesfailure renders theObservableStatuschange as a readable inline diff, which is the #749 case exactly.test/release-gate.test.mjs, 38 tests, no emulators needed (npm run test:gate). A gate that silently stops gating is worse than no gate, so the detection logic is pinned by tests rather than by having been verified by hand once. Fixtures are byte-faithful to the shapes that shipped, including the 4.2.5requireshim. Each check was mutation-tested (revert the logic, confirm a test fails), which caught one case where the size tolerance value was unpinned even though the check itself was covered.Workflow hardening (zizmor)
Two of the five commits are unrelated to the gate. This is the first PR to touch
.github/workflows/, and the zizmor scan only runs when a workflow file changes, so it fired here for the first time against a pre-existing, repo-wide condition:main'stest.yamlscores 34 findings (17 high, 8 medium). The check fails on any Medium or higher, so partial fixes would not clear it.unpinned-uses, the only mandatory check. Only 3 of the 15 were lines this branch added.permissions: contents: readandpersist-credentials: falseon the four checkouts. Clearedexcessive-permissionsandartipacked. No job writes to the repo viaGITHUB_TOKEN, and the publish job authenticates to npm withNODE_AUTH_TOKEN; that job has no checkout step and is otherwise unchanged.The 6 remaining
cache-poisoningfindings are pre-existing and already suppressed by the CI config. Fixing them would mean restructuring how the workflow caches around the publish path, which does not belong here.Notes for review
The obvious grep for #765 item 1 does not work, which is why the check looks the way it does. The issue suggests grepping the ESM dist for
require(. The shipped 4.2.5 bundle never writesrequire(: rolldown emittedtypeof require < "u"guards andrequire.apply(this, arguments), and minification renamed__commonJSto a single letter. A literal implementation of item 1 would have passed 4.2.5 clean. The gate matches the barerequireidentifier instead, across every ESM chunk rather than just the entry, since nothing guarantees the build stays single-chunk.On #749 wanting enforcement, not a warning. A textual diff cannot classify additive versus non-additive on its own. So the gate fails on any
.d.tsdelta, and the acknowledgement isnpm run gate:acceptplus a committed baseline, which puts the semver decision in front of a reviewer as a diff. Semantic classification via api-extractor would remove the judgement call but is a much larger project, deliberately not attempted here.Incidental repo changes. An eslint
**/*.mjsoverride, since typescript-eslint switchesno-undefoff for.tsand.tsxbut not for plain.mjs;scripts/added to thelintandformatglobs; a newtest:gatescript; and a repair to thesize-limitconfig, which pointed at TSDX-era filenames the vite build has not emitted for some time, sonpm run sizewas silently checking nothing.Follow-ups, not in this PR
reactandfirebaseinstalled, which would cost the gate its dependency-free property. Item 7 (runtime smoke render against Next App Router and Vite) is the strongest check and the only one that would have caught App Router crash: reactfire 4.2.4/4.2.5 throw "dynamic usage of require is not supported" on the client #759 by observing the crash rather than pattern-matching the artifact, but it is roughly a day and adds real CI minutes and flake surface. Neither is needed to close the two holes that actually shipped.no-cjs-in-esmhas nogate:acceptescape hatch, and matching the barerequireidentifier means it can also match the word inside a string literal or comment. Risk is latent rather than imminent:rxjsESM has zero occurrences, andrxfire's 8 are all in.cjs.jsfiles the ESM bundle never resolves. Fix is stripping strings and comments before matching.needs: [test, verify-package]edge. Making it required is about blocking merges.docs.yamlis still unhardened (2 medium, 2 high, plus 2 unpinned refs) and will surprise whoever edits it next.filesincludessrc, and npm does not exclude a nestednode_modules. A localnpm packwithsrc/nextjs/node_modulespresent produces a 186 MB tarball. Harmless in CI, but a live hazard for feat(nextjs): add firebase-cookie-middleware with security hardening #739: ifsrc/nextjslands as-is, everything under it publishes to npm. Thefilesallowlist probably wants narrowing as part of that packaging decision. The gate's local pack works around it by staging fromgit ls-filesplusdist/.Closes #749
Addresses #765 (items 1, 2, 4, 5)
🤖 Generated with Claude Code