ci: add built-artifact release gate (#765) - #766
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
@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. |
6135f29 to
21492b4
Compare
armando-navarro
left a comment
There was a problem hiding this comment.
Thanks Tyler. Reviewing the PR as it stands now, with the npm comparison and the new load test in. I verified the reworked machinery end to end and it holds up. Approving. The asks below are non-blocking on today's state, though the first two deserve attention before or right after merge.
What I verified
- The 94 tests pass (69 gate, 25 load), and a live gate run against npm 4.2.6 passes with 0.0% entry deltas. Since both sides are now measured on the same machine, the compression-environment noise a committed baseline would carry is designed away.
- The acknowledgment lifecycle behaves: an unacknowledged type drift fails,
gate:acceptlets exactly that surface through, and any later edit invalidates the digest. The suite also pins that a new npm release expires an old acknowledgment. - With the registry unreachable, the gate fails loudly after three attempts instead of skipping. A skip would be indistinguishable from a pass, so this is the right call, and the failure text says so.
- The load test catches the real thing: against the published 4.2.5 tarball it fails with the exact rolldown
requirecrash on the ESM entry, and the current build loads both entries cleanly on React 18. - The version rule blocks a type change shipped as a patch (the 4.2.4 case), stays quiet for CI's
-expversion rewrite today while the majors match, and still applies to prereleases of a genuine bump. - Pipeline wiring:
publishneeds the test job plus both verify jobs on both publishing triggers, the new job reuses the same pinned action commits, needs no secrets, and runs on fork PRs.
The description no longer matches the PR
The body still describes the committed-baseline design. Five spots are now wrong:
- The "Decision needed before merge" section: the code has decided it.
- "Implements #765 items 1, 2, 4, 5": item 3 is in.
- The follow-ups bullet saying items 3 and 7 are not implemented.
- "38 tests": it is 69 plus 25 now.
- The new load-test job is not mentioned at all.
One more thought: since that decision had been explicitly flagged for Jeff, a short comment noting the switch and why would let his review start from the right frame rather than from the stale table.
On the footer specifically: your own description says that under the npm route "Closes #749" should become a plain reference with the issue left open, so that line wants the same update, with the final close-or-keep-open call staying with Jeff as you originally framed it. To be fair on the merits, the implementation does deliver #749's literal ask, so if Jeff prefers to let the merge close it, that is defensible too.
The latest comparison will block every v4 release once v5 ships
This one is invisible today and painful later, and since the v5 branch already exists I want it on the record now. I ran the version helpers against the future state where 5.0.0 holds npm latest:
- A v4 maintenance release
4.2.7reads as a release candidate withisMinorOrMajorBumpfalse, so the gate fails it as "a patch bump over 5.0.0". - A v4 minor
4.3.0is misjudged the same way, because a lower major can never register as a bump. - Even a stamped canary build (
4.2.6-exp.<sha>) reads as a release candidate against5.0.0, and every v4 PR fails the types diff against the v5 surface before the version rule even runs. So the blast radius is the whole v4 line's CI, not just release cuts. - Acknowledging clears the types diff but not the version rule, so a contributor who hits this cannot get green without a code change. The change itself is small (the comparand is one constant), which is exactly why it is worth planning rather than discovering.
Fine to land as-is today (latest is 4.2.6, so nothing fires). My ask is just to file it with a clear "before 5.0.0 takes latest" milestone, or fix it here if you prefer. Two shapes that seem workable: make the compare target branch-aware (a per-branch dist-tag, or derive it from the candidate's own major), or make the version rule major-aware. Relatedly, once the gate lands on v5, every PR there will diff against the v4 surface and demand acknowledgment churn against a baseline that means nothing for that branch, so the same fix helps both.
One gate:accept covers types and size together
accepted.json records the type digest and the size snapshot in one step, and the size check honors whatever the snapshot says:
- I built the ordinary case: a candidate with one benign added declaration file and a tarball 40% over published, acknowledged the normal way, passes with zero failures. The +40% shows up only as a log note.
- The committed file records the size as a raw byte count with no delta or percentage, so nothing in the acknowledgment diff itself flags that a size regression rode along with the type change.
- Bounded, to be fair: the primary inlining detectors do not consult the acknowledgment at all, so this only softens the size backstop.
Some cheap improvements:
- Have
gate:acceptprint the size deltas it is about to bless, so whoever runs it sees what they are signing before committing (recording a readable delta in the file would go one better and put it in the PR diff too). - Have it run the three absolute checks first and refuse to record on failure, which also keeps a broken build from becoming anyone's blessed snapshot.
- A related docs nit: the README's accepting-a-change section skips the build-first step its running-it section includes, so on a fresh clone the accept command errors until you build.
Two carried notes on the checker itself
- The checks never look at
package.json's shape, only at whether referenced paths exist. A tarball with therequireexport condition removed,typingsdeleted, andpeerDependenciesdeleted still passes everything (I re-ran this against the current head). An in-gate assertion on the extracted tarball's critical fields, excludingversionsince CI rewrites it, would close it. - Nothing covers the wiring from a non-empty failures list to a non-zero exit. It works today (I checked all three exit codes), but a refactor could leave the gate exiting 0 forever with every test green. One child-process test against a crafted bad tarball would pin it.
Small notes
- The load job has no retry or outage handling the way the gate's fetch does, and neither new job caches npm downloads, so every PR pays two uncached installs per React version. Fine to leave, worth knowing.
- The load fixture pins firebase to
^11.10.0, which matches the repo's own devDependency today. When the repo moves to firebase 12, the fixture will quietly keep testing against 11 unless the constant moves with it. A one-line comment tying the two together would prevent the drift. type-checkis not inpublish's needs list (pre-existing, not this PR): a type error in source would not block a canary.
If any of this reads wrong, say so and I will dig back in. The version-helper runs and the coupling fixture are one-liners to re-run.
|
Thanks Armando, this was a genuinely useful review. Two of your asks are fixed and pushed (head is now The
|
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
@armando-navarro flagging you directly, since my reply above asks you something and I did not mention you in it. The part that needs you rather than just informing you: this is now marked ready and shows as approved off your 08:48 review, but that review predates the two commits that changed the PR's scope. The So: happy to hold the merge until you have re-looked. No rush at all, this can wait until you are back. Fixed from your review in the meantime: the |
…fies it Brings the `types` check into this pull request rather than the gate's, so the diff and its interpretation arrive together. FirebaseExtended#766 now covers FirebaseExtended#765, the runtime half; this covers FirebaseExtended#749, the type half. The check reports what moved and does not fail on it, because a type change is not a regression: adding a hook legitimately changes the surface. Whether the change is *breaking* is `Check API compatibility`'s answer. What still fails here is the bump: once package.json's numeric version moves off the published one, a changed type surface cannot ship as a patch. That is 4.2.4 stated precisely, and it is the half the differ cannot give, since adding an optional property to an interface is invisible to assignability but is still a minor. No acknowledgment. The `gate:accept` ritual existed to make a human decide additive against breaking; with that decided mechanically it bought a signature rather than a judgement, at the cost of reddening CI over a JSDoc edit and expiring every open pull request's acknowledgment on each release. It survives for `size`, which has no classifier. Two things worth flagging for review: `reportUnavailable` now reports a shared cause once, tracked on the report object. Both comparison checks fail for the same reason when the registry is unreachable, and one error under two check names buries it. Deliberately not done by having `size` defer to `types`: that coupling is invisible, and it already bit once, where removing `types` turned a registry failure into a silent skip because `size` had been staying quiet on its behalf. `check.mjs` now extracts the candidate before fetching, so it can derive the comparison target from the candidate's own major. It was calling `fetchPublished` with no version, which the gate now refuses rather than silently defaulting to the `latest` dist-tag. Without this the differ would compare every v4 build against the v5 surface once 5.0.0 ships, which is the defect the gate fixed.
jhuleatt
left a comment
There was a problem hiding this comment.
I appreciate the work, but need some convincing that this is worth the complexity it brings. release-gate.mjs alone is 700 lines, and I'm not confident I could debug it if it breaks down the line.
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 |
There was a problem hiding this comment.
Thanks for making the Zizmor changes, but please pull them out into their own PR and also change the docs workflow. That will keep this PR more focused
There was a problem hiding this comment.
Done, split out as #767, with docs.yaml included. test.yaml goes from 8 medium to 0, and docs.yaml clears entirely. 32 lines, no functional change to any job.
Worth flagging one thing: I applied the hardening to main directly rather than cherry-picking the two commits from here, because they were authored on top of the gate and carry the verify-package job with them. So this PR will still show the workflow changes until #767 lands and I rebase.
These commits will come out of this PR as part of the rework.
| }); | ||
| }); | ||
|
|
||
| describe('parseProbeOutput', () => { |
There was a problem hiding this comment.
Some of these tests don't seem worth the extra lines of code. Could you please trim the test files down to core functionality?
There was a problem hiding this comment.
Done. 25 tests down to 9, against a 220-line script.
One thing worth flagging: while trimming, I broke the require() half of the check on purpose to see if any test would notice. None did. Everything stayed green while the check was only really testing import(). The old 25 missed it too, so the trim exposed it rather than caused it. The 9th test is the one that catches it.
| * Checks: | ||
| * 1. no CJS interop / dynamic `require` in the ESM entry (issue #765, item 1) | ||
| * 2. externals stay external, nothing unexpected is inlined (issue #765, item 2) | ||
| * 3. every path in `exports`/`main`/`module`/`typings` exists (issue #765, item 4) |
There was a problem hiding this comment.
3 should be covered by the test suite, correct?
There was a problem hiding this comment.
No. Check 3 there is exports-map (#765 item 4), and the test suite never loads the packed tarball, so nothing in it would notice a path in exports missing from the shipped files.
publint --strict covers it now, along with manifest problems we weren't checking at all. It caught a live one: neither exports branch had a types condition, so CJS consumers on node16 got no declarations. Fixed in this PR.
Replaces the bespoke 700-line release gate with off-the-shelf tooling, keeping only the one check nothing off-the-shelf covers. Two new jobs run against the packed tarball the publish job uploads verbatim, so they check the exact bytes that ship: Verify package publint --strict, attw, size-limit Verify package loads entry-load.mjs on React 18 and 19 publint and attw cannot see the #759 class: their output is identical across 4.2.3 through 4.2.6, because that regression was a require call inlined into the body of a structurally valid ESM file. Loading the package is the only thing that catches it, which is why entry-load.mjs survives. They do catch two live consumer-facing bugs the bespoke gate never did. Published 4.2.6 gives TypeScript CJS consumers under node16 resolution no type declarations at all, and the package declared no sideEffects. Both are fixed here: types conditions on both exports branches, a duplicated .d.cts, and sideEffects false. Also repoints the size-limit config, which named TSDX-era filenames the vite build stopped emitting, so npm run size was checking nothing. publint runs with --strict because it exits 0 on warnings, and the missing types condition is a warning. attw ignores internal-resolution-error via .attw.json: the emitted .d.ts use extensionless relative imports, which is a real pre-existing bug with its own fix, and ignoring the one rule keeps every other resolution check enforcing. Addresses #765 items 1 to 5. Item 6 is #749 and lands separately.
b50ede7 to
ce5bf75
Compare
|
Reworked this in place rather than opening a new PR, so your comments stay attached. It's a different PR now: Description is rewritten and all five of your comments have replies. Three are on files that no longer exist, so they'll show as outdated. Based on #767 so the diff here is only this PR's own work. It'll retarget to |
Dismissing for re-review: this approval predates a full rewrite of the PR. It was submitted against 47e3f96, which is no longer in this branch. release-gate.mjs, its tests and the README have been removed and replaced with publint, attw, size-limit and one load script. Already re-requested, no action needed until you are back.
Summary
Rewritten after your review. The 737-line
release-gate.mjs, its 666-line test file and therelease-gate/README.mdare all gone, replaced with three off-the-shelf tools and one small script.Based on #767, so this PR shows only its own diff. GitHub will retarget it to
mainwhen that merges.Two dist-level regressions shipped as patch releases with nothing in the flow checking the built artifact:
ObservableStatus<T>into a discriminated union, breaking 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, soimport('reactfire')threw (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).The source was fine both times. Only the emitted artifact regressed.
What runs
Two jobs, both against the same
reactfire.tgzthepublishjob uploads, so they check the exact bytes that ship.Verify package
publint --strictexportsconditions, bad paths, files referenced but not shippedattwsize-limitVerify package loads (React 18 and 19) installs the tarball into a throwaway project with real
reactandfirebase, then imports both entry points.Why one script survived
Neither
publintnorattwcatches #759. Their output is byte-identical across 4.2.3, 4.2.4, 4.2.5 and 4.2.6. Both analyse package structure, and #759 was arequireinlined into the body of a structurally valid ESM file. Actually loading the package is the only thing that catches it:import('reactfire')require('reactfire')Only the ESM entry breaks, so a check that loaded a single entry point would have missed it.
scripts/entry-load.mjsis 220 lines, covered by 9 tests (down from 25).Two live bugs this surfaced
typescondition on eitherexportsbranch, so CJS consumers on node16 resolution got no type declarations at all. Predates 4.2.4. Fixed here, and that fix is what takespublintfrom exit 1 to "All good!"..d.ts(from './useObservable'), which node16 resolution rejects. Real bug, and the fix belongs insrc, so it gets its own issue rather than riding along here. Suppressed via.attw.jsonso every other resolution check keeps enforcing. Deleting that one ignore rule is the acceptance test for the fix.Separately,
size-limitwas already a devDependency and already pointed at TSDX-era filenames the vite build stopped emitting, sonpm run sizewas silently checking nothing.What this gives up
Worth stating plainly rather than leaving you to find it:
no-cjs-in-esmandexternalschecks means App Router crash: reactfire 4.2.4/4.2.5 throw "dynamic usage of require is not supported" on the client #759's class is caught by observing the failure rather than pattern-matching for it. Sufficient for App Router crash: reactfire 4.2.4/4.2.5 throw "dynamic usage of require is not supported" on the client #759, which throws on a Node import. An inlining that breaks only in a browser bundler would slip past. That gap is Release process: add a built-artifact (dist/bundle) check to catch runtime regressions before publish #765 item 7, re-homed to the V5 demo app.size-limit's absolute budget will not catch a regression inside the current 3.7 kB of headroom. The bespoke ±2% delta would have. For scale, App Router crash: reactfire 4.2.4/4.2.5 throw "dynamic usage of require is not supported" on the client #759 moved the bundle +3.1%.Scope
Addresses #765 items 1 to 5. #765 stays open for item 7.
The
.d.tssurface check for #749 is a separate follow-up usingapi-extractor: about 35 lines of config plus a generated report, no network, no comparison release to fetch.