Skip to content

build(licenses): every build carries its third-party notices, checked against a committed manifest - #318

Merged
MerciHanrim merged 1 commit into
mainfrom
feat/third-party-notices
Oct 5, 2026
Merged

MerciHanrim merged 1 commit into
mainfrom
feat/third-party-notices

Conversation

@MerciHanrim

Copy link
Copy Markdown
Owner

Part of #301 — the first of its two pull requests: the manifest, the generator, the notices in every build, and the build checks. No interface changes; the screen that shows the notices comes in the second.

What ships now

Build Notices Components
web (npm run build) THIRD_PARTY_NOTICES.txt 28
portable (npm run build:portable) the same text inside the HTML, in <template id="third-party-notices"> 28
PWA (npm run build:pwa, and the Cloudflare Production build) THIRD_PARTY_NOTICES.txt, precached by the service worker 37
  • web and portable: 26 npm packages (25 names; zustand ships in two versions) plus the Vite and Rolldown runtime helpers. The two texts are byte-identical.
  • PWA: the same 28 plus the service worker's six Workbox packages, idb, the workbox-build template and the Apache-2.0 loader of @trickfilm400/rollup-plugin-off-main-thread.
  • Each component appears with its version, SPDX licence, copyright lines and the full text of every licence and NOTICE file; a text several components share is printed once.
  • Vite and Rolldown are listed as "MIT + bundled third-party notices". Vite's whole LICENSE.md is included: its own MIT licence, then the licences of the code it bundles. Rolldown's own MIT LICENSE and its THIRD-PARTY-LICENSE are separate inputs, each pinned by its own SHA-256 and labelled with what it covers.
  • The header keeps "Copyright © 2026 Hanrim. All rights reserved." and calls the list "Third-party open-source licenses". Loop Studio itself is not described as open source, and no root LICENSE or license field is added.

How the list is made

  • scripts/third-party-notices/vite-plugin.mjs reads the build itself: every chunk's module ids (before the portable build inlines its chunks), the @fontsource packages our CSS names, the emitted font files' origins, and the virtual modules of the Vite and Rolldown runtime.
  • Vite's own build.license list is used as evidence and then removed from the output. On its own it is not enough: it misses @fontsource/ibm-plex-sans-thai (whose files our CSS names directly), it cannot see the service worker, and it is empty in the single-file portable build.
  • licenses/registry.json lists what the module graph cannot show, with the evidence that proves each item is in the output, and the review of the one Apache-2.0 component. It has no NOTICE file; its copyright line comes from the loader's own header.
  • Nothing is fetched: everything is read from node_modules, the lockfile-installed packages and the repository. Two runs of npm run licenses:update gave a byte-identical manifest.

The portable copy is text, never markup

  • &, < and > are escaped, so the template's content holds no <: whatever a licence text says, it cannot close the template, open a <script> or add any element. The writer refuses U+0000 and CR, which an HTML parser would drop or rewrite, and inserts the template before the last </body> by slicing, so a $& in a text stays literal.
  • Read with template.content.textContent, the DOM gives back the exact bytes of the web build's THIRD_PARTY_NOTICES.txt; the manifest pins the same SHA-256 for both builds.
  • A unit test feeds the writer a text built to break out (</template><script>…, an onerror image, a second template, $&). A new test in e2e/portable-file.spec.ts reads the real file's DOM against the manifest, then parses that hostile text in Chromium: one template, one text node, nothing runs.
  • core.mjs and licenses/README.md fix the contract for the licence screen in the second pull request: the notices are rendered as text, never through innerHTML or dangerouslySetInnerHTML.

What fails the build

licenses/third-party-manifest.json pins each build: components, versions, SPDX, every licence and NOTICE file with its SHA-256, copyright lines, and the SHA-256 of the whole text. A build that ships anything else fails and prints the difference. The rules, in scripts/third-party-notices/core.mjs and tested in scripts/third-party-notices.test.mjs (49 tests):

  • only MIT, ISC, BSD-3-Clause and OFL-1.1 pass by themselves; Apache-2.0 only with a review entry, so a new Apache-2.0 package never passes by itself; GPL-family licences never;
  • UNKNOWN or a missing licence, a missing licence file, a missing copyright line, an OR expression without a recorded choice, and a registry item marked unresolved fail;
  • a new, removed or upgraded package, a changed licence text and an added or removed NOTICE file each show as a difference;
  • a local absolute path in the manifest or the notices fails the build.

After the builds, npm run check:third-party-notices (a new CI step, and on the Cloudflare Production path) checks that the notices are in each output and match the manifest, that the portable copy is escaped and pinned to the web file's bytes, that Vite's licence list is not deployed, that sw.js precaches the file, that every service-worker item's marker is in sw.js or the Workbox chunk, and that no unlisted Workbox package ships.

Verification (local)

  • npx tsc -b, oxlint (39 warnings, the existing baseline), 3,077 unit tests and all check scripts pass.
  • The three builds and the Cloudflare Production path (CF_PAGES_BRANCH=main) build, and check:third-party-notices, check:pwa-closure and check:no-pwa pass.
  • Negative checks on real builds: a manifest with another version, another licence hash, an extra NOTICE file or a missing package each failed the build with the matching difference; a deleted or altered notices file and a service worker that does not precache it each failed the post-build check.
  • Portable copies with a raw <, a stray &, an altered text, no template or two templates each failed the post-build check. In Chromium, the hostile text left unescaped built a <script> and an <img> and ran; escaped, it built nothing.
  • End-to-end: production bundle 15 of 15 and PWA 18 of 18 before the escaping change; the portable project, re-run after it, 16 of 16 including the new test; none failed or retried. The full five-shard suite is left to this pull request's CI.

Not in this pull request

… against a committed manifest

Part of #301, the first of its two pull requests. No interface changes; the
screen that shows the notices comes in the second.

- Every build now ships the notices: THIRD_PARTY_NOTICES.txt in the web and
  PWA builds (the PWA precaches it), and the same text inside the portable
  file's HTML, in <template id="third-party-notices">. Each shipped
  component appears with its version, SPDX licence, copyright lines and the
  full text of every licence and NOTICE file; a text that several components
  share is printed once. The header keeps "Copyright © 2026 Hanrim. All
  rights reserved." and calls the list "Third-party open-source licenses";
  Loop Studio itself is not described as open source.
- scripts/third-party-notices/vite-plugin.mjs gathers what ships from the
  build itself: every chunk's module ids (read before the portable build
  inlines them), Vite's own build.license list as evidence (then removed from
  the output, since it misses fonts referenced from CSS and is empty in the
  single-file build), the @fontsource packages our CSS names, the emitted font
  files' origins, and the virtual modules of the Vite and Rolldown runtime.
- The portable copy is text, never markup: `&`, `<` and `>` are escaped, so
  no licence text can close the template or add an element; the writer
  refuses U+0000 and CR, which an HTML parser would drop or rewrite; it is
  inserted before the last </body> by slicing, so `$&` in a text stays
  literal. Read back with `template.content.textContent`, it is
  byte-identical to the web build's THIRD_PARTY_NOTICES.txt. A unit test
  feeds the writer a text built to break out (`</template><script>`, an
  onerror image, a second template); an e2e test reads the real file's DOM
  against the manifest and parses the hostile text in Chromium: one
  template, one text node, nothing runs. core.mjs and licenses/README.md
  state the contract for the licence screen: render as text, never through
  innerHTML or dangerouslySetInnerHTML.
- licenses/registry.json lists what the graph cannot show: the Vite runtime
  (its whole LICENSE.md, "MIT + bundled third-party notices"), the Rolldown
  runtime (its own MIT LICENSE and THIRD-PARTY-LICENSE, each pinned as its
  own input with what it covers), and the PWA service worker's six Workbox packages,
  idb, the workbox-build template and the Apache-2.0 loader of
  @trickfilm400/rollup-plugin-off-main-thread (no NOTICE file; its copyright
  line comes from the loader's header). It also holds that loader's licence
  review.
- licenses/third-party-manifest.json pins each build: web and portable 28
  components, PWA 37, with every licence file's SHA-256, NOTICE files (none
  today), copyright lines and the SHA-256 of the whole text. A build that
  ships anything else fails and prints the difference; `npm run
  licenses:update` rewrites the manifest offline from three real builds, for
  review. Two runs give byte-identical output. Neither the manifest nor the
  notices may hold a local absolute path; the build and the check fail if
  they do.
- scripts/third-party-notices/core.mjs holds the rules, tested on made-up
  packages in scripts/third-party-notices.test.mjs (49 tests): only MIT, ISC,
  BSD-3-Clause and OFL-1.1 pass by themselves; Apache-2.0 only with a review
  entry; GPL-family licences never; UNKNOWN, a missing licence file, a
  missing copyright line, an unresolved registry item and an OR expression
  without a recorded choice fail; a new, removed or upgraded package, a
  changed licence text and an added NOTICE file each show as a difference.
- scripts/check-third-party-notices.mjs runs after the builds in CI: the
  notices are in each output and match the manifest, the portable copy is
  escaped and pinned to the web file's bytes, Vite's licence JSON is not
  deployed, sw.js precaches the file, every service-worker item's marker is
  in sw.js or the Workbox chunk, and no unlisted Workbox package is.
- Declared internal; the version stays 0.17.1.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying cozy-loop-studio with  Cloudflare Pages  Cloudflare Pages

Latest commit: b366461
Status: ✅  Deploy successful!
Preview URL: https://9968c212.cozy-loop-studio.pages.dev
Branch Preview URL: https://feat-third-party-notices.cozy-loop-studio.pages.dev

View logs

@MerciHanrim
MerciHanrim merged commit 401238a into main Oct 5, 2026
10 checks passed
@MerciHanrim
MerciHanrim deleted the feat/third-party-notices branch October 5, 2026 01:28
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