Skip to content

Stop committing the renderer bundle - #122

Merged
juanmaguitar merged 1 commit into
trunkfrom
juanmaguitar/issue-120-committed-renderer-bundle
Aug 5, 2026
Merged

Stop committing the renderer bundle#122
juanmaguitar merged 1 commit into
trunkfrom
juanmaguitar/issue-120-committed-renderer-bundle

Conversation

@juanmaguitar

@juanmaguitar juanmaguitar commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #120.

The esbuild output at src/renderer/index.js and index.css was committed so the app could run without a build step. Regenerating it was a manual step at the end of any renderer change, and forgetting it was silent — the file is not reviewed (2.6 MB of generated output), the linter ignores it, and no job compared it against a fresh build.

It had drifted. Rebuilding on a clean npm ci, with the committed lockfile and the same esbuild (0.23.1), produced 323 insertions / 177 deletions: 33 hunks from index.jsx, 4 from setup-steps.cjs, 1 from update-plan.cjs, and ~60 inside vendored @wordpress/* modules. Most visibly, the isUpdating flag from #111 was absent, so Install / Build / Dev were not gated while a site update ran.

One correction to the issue, which decides the approach

The signed artifacts were never affected. All three Buildkite steps already ran npm run build:once before npm run dist, so shipped builds were made from source and did have the gating. The stale bundle only bit someone running electron . from a checkout.

That left the committed file with no consumer in the shipping path. So this deletes it and makes the build unskippable, rather than adding CI to police a file nobody needs. Drift becomes impossible rather than merely detected — the second of the two directions in the issue.

npm install is already mandatory (main.js needs electron-store, isomorphic-git, …), so hanging the build off the existing entry points costs contributors nothing.

What changed

  • The output is gitignored and built by postinstall, start, and every dist script.
  • Prefixing start also closes a latent race: concurrently could hand Electron a bundle the first watch build had not written yet. The committed file happened to mask that.
  • Buildkite's three separate "Build renderer" steps are gone — the build lives in one place and now covers a local npm run dist too.
  • src/renderer/bundle.js / bundle.css removed. Nothing loaded them; leftovers from when the output had a different name.

One deviation from the issue

The issue asks for the bundle lint ignores to go. src/renderer/index.js stays ignored: it is no longer committed, but it still sits next to its own source after any build, so dropping the ignore would lint 55k lines of generated code on a developer machine and nothing on a fresh CI checkout. Only the bundle.* ignores went.

Testing

Verified locally:

  • Fresh npm install builds the bundle via postinstall; both files resolve as ignored.
  • npm test — 147 pass. eslint . --max-warnings=0 — clean, checked both ways: with the generated bundle present (a developer machine after any build) and without it (CI's npm ci --ignore-scripts skips postinstall, so the bundle never exists there). Keeping src/renderer/index.js in the ignores matters more now that Finish the lint baseline and widen the CI check to the whole repo #119 lints the whole repo at zero tolerance — CI never sees the file, so only a local run would have tripped over it.
  • The gating is now in the built bundle: 3 × || isUpdating on Install / Build / Dev, absent from what was committed.
  • electron-builder --dir produces an .app whose asar contains src/renderer/index.js and index.css despite the gitignore (electron-builder does not read .gitignore), and the packed bundle has the gating. Launched it — boots and renders.
  • Rebuilding leaves git status clean, so the drift class is gone rather than fixed once.

Verified on a signed artifact from Buildkite build #261, all three platforms green. The macOS .dmg was installed and exercised on both paths:

  • an existing older site updated to latest trunk successfully, with the app behaving normally throughout
  • a new site created from scratch, with every step of the initial setup checklist working as expected

That is the end-to-end check this issue was really about — the shipped renderer now matches its source.

Rebased onto trunk after #119 landed. The only conflict was in .github/workflows/lint.yml, where #119 deleted the changed-files job that held a comment naming the old bundle.js; trunk's version was taken wholesale, so that file is no longer part of this diff. Nothing in the rebase touches the app, so the manual testing above still stands.

Note for reviewers with a local checkout: after pulling, src/renderer/index.js and index.css become untracked-and-ignored. Harmless — npm install regenerates them.

🤖 Generated with Claude Code

The esbuild output at src/renderer/index.js and index.css was committed so
the app could run without a build step. Regenerating it was a manual step at
the end of any renderer change, and forgetting it was silent: the file is not
reviewed (2.6 MB of generated output), the linter ignores it, and no job
compared it against a fresh build.

It had drifted. Rebuilding on a clean `npm ci`, with the committed lockfile
and the same esbuild, produced 323 insertions and 177 deletions — 33 hunks
from index.jsx, 4 from setup-steps.cjs, 1 from update-plan.cjs, and ~60 inside
vendored @wordpress/* modules. Most visibly, the `isUpdating` flag added by
the update path (#111) was absent, so the Install, Build and Dev buttons were
not gated while a site update ran.

The signed artifacts were never affected: all three Buildkite steps already
ran `npm run build:once` before `npm run dist`. That left the committed file
with no consumer in the shipping path — pure liability. So delete it and make
the build unskippable, rather than adding CI to police a file nobody needs.
`npm install` is already mandatory (main.js needs electron-store,
isomorphic-git, ...), so hanging the build off the existing entry points costs
contributors nothing.

- The output is gitignored and built by `postinstall`, `start` and every
  `dist` script. Prefixing `start` also closes a latent race: `concurrently`
  could hand Electron a bundle the first watch build had not written yet,
  which until now the committed file happened to mask.
- Buildkite's separate "Build renderer" steps go, so the build lives in one
  place and covers a local `npm run dist` too.
- src/renderer/bundle.js and bundle.css are removed. Nothing loaded them; they
  were left over from when the output had a different name.

src/renderer/index.js stays in the eslint ignores. It is no longer committed,
but it still sits next to its own source after any build — dropping the ignore
would lint 55k lines of generated code on a developer machine and nothing on a
fresh CI checkout.

Fixes #120

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juanmaguitar
juanmaguitar force-pushed the juanmaguitar/issue-120-committed-renderer-bundle branch from efc68cd to 89f2030 Compare August 5, 2026 17:12
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

0 findings — 0🔴 0🟡 0🔵. No [fix here] or [follow-up] items across the five dimensions.

This PR stops committing the esbuild renderer output (src/renderer/index.js, index.css) and removes the dead bundle.js/bundle.css leftovers (confirmed unreferenced by index.html or anywhere else). The build now hangs off postinstall, start, and every dist:* script instead of a manual step, closing the drift the commit message documents (the committed bundle was missing the isUpdating gating flag from #111).

Checked against the five dimensions:

  • Architecture: no violation of the zero-prerequisites promise — this build step runs at contributor-toolkit development time (npm install/npm start/npm run dist), not inside the packaged app's runtime spawns. Buildkite's install_node_dependencies.sh calls plain npm ci (no --ignore-scripts), so postinstall still runs build:once there; lint.yml's npm ci --ignore-scripts skips it, but index.js stays in the ESLint ignore list regardless of whether it's present.
  • Security: no IPC, spawn, or listener changes.
  • Performance: build:once now runs twice in the dist scripts (once via postinstall, once explicit) — trivial esbuild cost, not worth a finding per the 'no speculative micro-optimisation' guidance.
  • Cross-platform: plain npm scripts, no path or process-spawn changes.
  • Tests: this is a build-tooling fix, not an app feature/bugfix reachable from node --test; nothing to reproduce there.

This is a first-pass review (no prior findings to reconcile).

Style / process notes (non-blocking)

None beyond what's already covered above.

@juanmaguitar
juanmaguitar merged commit 3cfe987 into trunk Aug 5, 2026
4 checks passed
@juanmaguitar
juanmaguitar deleted the juanmaguitar/issue-120-committed-renderer-bundle branch August 5, 2026 17:18
juanmaguitar added a commit that referenced this pull request Aug 6, 2026
Closes #116.

`url:open` passed whatever it received to `shell.openExternal`, which
hands an address to whatever application the OS has registered for its
scheme — a wider action than "show this page in the browser". A `file:`
address opens an arbitrary local path in its associated application (on
Windows that can mean running it rather than viewing it), and any other
registered scheme is reachable the same way.

Every caller passes an http/https address today, so nothing misuses it.
It matters as the second half of a chain: the renderer displays content
the app does not author, and the URL the app auto-opens on server start
is parsed out of the Playground server's stdout. This guard is the step
that keeps any future influence over that string from becoming an action
on the contributor's machine.

## What changed

- **`src/external-url.js`** (new) — `ALLOWED_URL_SCHEMES = ['http:',
'https:']`, plus `isAllowedExternalUrl()` and `openExternalUrl()`. The
scheme is read off a parsed `new URL()` rather than the raw string, so
casing and leading whitespace (`FILE:`, ` file:`) normalize before the
comparison instead of being a way around it; an address Node can't parse
is refused rather than handed to the OS to interpret. What gets opened
is the parser's own `href`, not the caller's string. That second half
matters as much as the allow-list: the URL parser strips tabs and
newlines from anywhere in the input, including the middle of the scheme,
so `ht\ntp://example.com/x` validates as `http:` — and forwarding the
raw text would hand the OS an address nothing had checked. For every
caller here the two forms differ only by the trailing slash the parser
adds to a bare origin.
- **`src/main.js`** — the handler delegates to that module, passing
`shell.openExternal` and an `onRefused` that logs through the existing
`logEvent` (scope `url`). A refusal is visible in the log file people
attach to bug reports, not silent, so a future caller that trips the
guard is diagnosable. The logged address is truncated, since by
hypothesis it is the influenced input.

The logic lives in its own module, per the repo convention
(`src/bind-loopback.js`), so both sides of the guard are testable
without an Electron process.

No user-facing behaviour changes: every existing caller passes
http/https.

## Testing

`test/external-url.test.cjs` — 11 tests, using a stub in place of
`shell.openExternal` so "did this reach the OS?" is an assertion rather
than something the test takes on trust:

- The four address shapes the app actually passes (Trac, the feedback
form, the site on an ephemeral loopback port, wp-admin) still open, and
reach the stub in their normalized form.
- A control-character case (`ht\ntp://example.com/x`) pins the
normalization: it opens `http://example.com/x`, not the string carrying
the newline. This test fails against the first commit of this PR, which
forwarded the raw input.
- `file:///etc/passwd` and `file:///C:/Windows/System32/cmd.exe` never
reach the stub. The Windows one is the case that matters most: the OS
association for a `.exe` is "run it".
- `javascript:`, `data:`, `mailto:`, and OS-registered third-party
schemes (`ms-msdt:`, `vscode:`) are refused too.
- Junk input (`''`, whitespace, `null`, `undefined`, a number, an
object, an array, `'not a url'`) is refused rather than thrown.
- A refused address cannot forge a log line: control characters are
escaped rather than passed through, so an address carrying a newline
can't close its entry and open another one in the app's own
timestamp-and-scope format. Truncation runs after escaping, since
escaping is what decides the final length.
- One test pins the allow-list itself, so widening it has to be a
deliberate change that shows up in a diff.

Suite: **158 passing, 0 failing**, on `node --test` and again on
Electron's bundled Node via `npm run test:electron`. `npm run lint` is
clean across the repo.

### To verify by hand

Nothing in the UI can reach the refusal path by design, so the manual
pass is about confirming the allowed side is unaffected. On a signed
build from this branch:

1. Start a site — it should still auto-open in the browser as before.
2. Click the site URL and the **wp-admin** link in the site header —
both open.
3. Open a site with the database tool and click through to **adminer** —
opens.
4. Generate a patch and click the **core.trac.wordpress.org** link in
the "Next steps" text — opens.
5. Click the **feedback form** link — opens.
6. Optional, to see the guard fire: from the renderer devtools console,
run `window.api.openExternal('file:///etc/passwd')`. It should resolve
`false`, nothing should open, and the log file (Help → the log path)
should contain a `url` line reading `refused to open file:///etc/passwd
— only http:, https: are allowed`.

Also updates `.github/instructions/code-review.instructions.md`: it
named this handler as its known-open calibration example, which stops
being true here. Replaced with what the fix teaches, so the next review
looks for both halves rather than re-reporting something already closed.

Rebased on trunk after #122/#125/#127; the review standard's calibration
example moved with it to `.github/instructions/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanmaguitar added a commit that referenced this pull request Aug 7, 2026
Closes #109. First step of #110, following [Option
A](#110 (comment)).

## Why

The app knows *where* a site is and *what state its build is in*, but
not *what the contributor is working on* — and Core work is organised
around Trac tickets, not directories. That gap is why a generated patch
carries no ticket number (#107), why there is nowhere to show a ticket's
existing patches, and why applying someone else's patch (#11) has no
context to start from.

## What this does

A site can now be associated with a Trac ticket:

- asked optionally at creation — *"What are you working on?"*
- set, changed or cleared later from a new panel on the site screen
- stored in `siteMeta`, so it survives restarts

Parsing accepts what a contributor actually types or pastes: a bare
number, a number with a `#`, and any core Trac ticket URL with a comment
anchor, trailing slash or `?format=` query still attached. Anything else
is rejected with a message aimed at the contributor rather than at a
log.

The panel sits between the action row and the Terminal; nothing else on
the page moves.

## What this deliberately does not do

**No network.** Linking a ticket never depends on Trac being reachable.

That is a design decision, not an omission. While building this I
measured every documented Trac read path — `?format=csv`, the ticket
HTML, `raw-attachment/ticket/<id>/<file>`, and even `robots.txt` — and
all of them currently return **403 behind a proof-of-work interstitial**
for any client that is not a browser (SHA-256 hashcash, `_hcc` cookie,
escalating to an "I am human" checkbox on repeat hits; a spoofed browser
User-Agent gets a bare nginx 403 instead). This also breaks core's own
`grunt patch`, which has parsed that page for years.

So *showing* a ticket's patches needs a fetch strategy of its own and
lands separately. Two things shape it:

- A Contributor Day room shares one NAT IP — exactly the pattern that
escalates Trac's challenge to interactive, and that exhausts
unauthenticated GitHub's 60 requests/hour. Any network path has to
degrade to opening the contributor's own browser, where the challenge
clears itself.
- The real fix is upstream: [meta
#8202](https://meta.trac.wordpress.org/ticket/8202) proposes a `ticket/`
JSON endpoint returning exactly the attachment data (filename, author,
date, size) that panel wants. The follow-up should keep its fetch source
swappable so it can adopt that endpoint when it lands.

Also out of scope here: applying patches (#11), the ticket number in the
patch header (#107), tickets as branches (#108), opening PRs from the
app (#118).

## Testing

- `test/trac-ticket.test.cjs` — 14 tests over every accepted and
rejected input form. `parseTicketRef` is a pure, dependency-free module
so it runs under `node --test` with no DOM, following the
`setup-steps.cjs` / `update-plan.cjs` convention.
- `npm test` and `npm run test:electron`: **172/172 pass** on both.
- `npm run lint` (now repo-wide after #119): clean.

Manual: create a site with and without a ticket; link, unlink and
re-link from the panel; paste `62281`, `#62281` and a URL with
`#comment:3`; restart the app and confirm the ticket survives; confirm
"Open in Trac" opens the external browser.

### Fixed after manual testing

Manual testing surfaced a wrong-message bug: a bare word like `abc` (and
`62281abc`, `#abc`) was rejected with *"Only core.trac.wordpress.org
tickets are supported"* — pointing the contributor at a host they never
named. `new URL('https://abc')` succeeds because a bare word is a legal
hostname, so non-URL input slipped past into the host check instead of
falling to the generic message. Fixed to only treat input as a URL when
it has a scheme, a path separator or a dotted host; everything else now
gets *"Enter a ticket number like 62281, or a core.trac.wordpress.org
ticket URL."* A github.com URL still correctly names the host as the
reason. The reproducing test asserts the message, not just rejection —
the gap the original suite missed.

Rebased onto trunk after #122, so no generated bundle is included — the
diff is five source files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
mokagio added a commit that referenced this pull request Aug 21, 2026
Three conflicts, all resolved in trunk's favour:

- `.buildkite/pipeline.yml`: this branch ran `npm run build:once` as its own
  Buildkite step before `npm run dist:win`.
  Trunk's `dist:win` now chains `build:once` itself (#122), so the explicit step
  is redundant.
  The hardening this branch adds — `Invoke-NativeCommand` around the native
  calls, and the extracted `verify_windows_signature.ps1` — is kept.
- `scripts/azure-sign.cjs`: trunk landed the same `/debug` passthrough with an
  explanatory comment; kept the comment.
- `test/azure-sign.test.cjs`: trunk moved the suite to `tests/unit/`, so the
  `require` of `scripts/azure-sign.cjs` gains a path segment.

`npm test` (1046 passing) and `npm run lint` are green on the merge result.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

The committed renderer bundle has drifted from its source, and nothing catches it

1 participant