xpi ができるまで(手でなぞれる手順)

scripts/build.rb がやっていることを、script を読まなくても分かるように書く。 同じ commit から誰がやっても同じ bytes が出るのが約束(reproducible)。ずれたら、どこでずれたかがこの手順で分かる。

道具: mise install(deno 2.9.6、ruby 3.4、node 24)、zip(Info-ZIP 3.0。mac も ubuntu もこれ)、git。 依存(tsdown/rolldown、birpc、@std/path)は tooling/webext-actors/deno.lock で固定(integrity 込み)。deno install --frozen で、lock と違う bytes が来たら止まる。

0. 材料

drops/<name>/drop.toml              uuid / name / note / contact / actors(uuid が正体、name は dir と同じ札)
drops/<name>/src/<actor>/actor.ts   作者が書いたもの。parent(メインプロセス)と content(ページ側)の宣言
tooling/webext-actors/              build.ts、_shared/(defineActor.ts、contentRuntime.ts、io.ts、xul.d.ts)、tsdown の設定、deno.json
drops/std-actor/src/lib/             殻(tsubakiActor / vnode / style / commands)。lib なので、配られるのは一枚だけ
scripts/build-drop.rb               xpi に固める(下の 3〜6。noraneko の testbed と同じ script)

1. 並べる(stage)

_stage/<name>/tooling/webext-actors/ の中身(build.ts _shared/ tsdown.*.config.ts deno.json tsconfig.json)を写し、 drops/<name>/src/<actor>/_stage/<name>/<actor>/ に写す。build.ts は自分の dir の下の _ で始まらない dir を actor と見なす。

mkdir -p _stage/<name>
cp -R tooling/webext-actors/{build.ts,_shared,tsdown.actor.config.ts,tsdown.content.config.ts,deno.json,deno.lock,tsconfig.json} _stage/<name>/
cp -R drops/<name>/src/<actor> _stage/<name>/<actor>
cp abi/v1.json _stage/<name>/abi.json

[deps] があるなら、dep は npm の package として並ぶ:

_stage/<name>/_deps/<dep>/lib/          その dep の src/lib
_stage/<name>/_deps/<dep>/package.json  name / version / type / exports(".": "./lib/index.ts")
_stage/<name>/_deps/<dep>/abi.json      その dep の source が ../abi.json を読むときだけ
_stage/<name>/package.json              dependencies: { <dep>: "file:./_deps/<dep>" }
_stage/<name>/deno.json                 nodeModulesDir: "manual"

deno installnode_modules/<dep>_deps/<dep> への symlink にする。あとは deno も rolldown も同じ規則で名前を引く -- import { runTsubakiActor } from "std-actor" が、 親側の束でも content 側でも、同じ所を指す。実行時にどこから来るか(content の scope に lib.js が先に読まれる)は external の側の話で、resolve とは別。

node_modules/ は組み直しのあいだ残す(消すと deno install が毎回 33MB を置き直して、 一周が 1.7 秒から 5 秒になる)。中身は deno.lock が決めているので、残しても混ざらない。

2. actor を build する(deno task build = build.ts)

_stage/<name>/mise exec -- deno install -q --frozen のあと mise exec -- deno task build。actor ごとに:

  1. <actor>/actor.ts を Deno で import して meta(id、version、namespace、matches、runAt)と parent のメソッド名を読む。
  2. _dist/<actor>/ に生成する(Firefox 自身の about:newtab add-on と同じ形。xpi は入れ物、ページへの道は JSWindowActor):
    • manifest.json: MV2、browser_specific_settings.gecko.id = meta.idversion = meta.versionhidden: true。それだけ (content_scripts も experiment_apis も background も無い。stock Firefox では about:* に content script が入らないため。 noraneko の webext-actors/README.md「addon 式が駄目だった理由」)
    • actor.json: JSWindowActor の登録に要るもの。name(Nora + PascalCase(actor)、例 NoraNewtab)、idversionmatches(meta.matches)、event(runAt から: document_start → DOMDocElementInserted、document_end → DOMContentLoaded、 document_idle → load)、methods(parent のメソッド名)、replacesincludeParent: truesafeForUntrustedWebProcess(web の match があるとき)
    • parent.sys.mjs: class <name>Parent extends JSWindowActorParentreceiveMessage で actor.mjs の parent[method](...args) (この時点では resource://noraneko-builtin/<actor>/actor.mjs を指す。3 で書き換わる)
    • child.sys.mjs: class <name>Child extends JSWindowActorChildevent で content.js を loadSubScript で読む (window / document / exportFunction / __nora を scope に載せる。__nora.callsendQuery)
  3. tsdown を二回走らせる(minify: false。読める形のまま):
    • actor.mjs(ESM): _gen/<actor>/parent.entry.ts から。parent だけ(content と birpc は tree-shake)
    • content.js(IIFE): _gen/<actor>/content.entry.ts から。_shared/contentRuntime.ts と birpc を同梱

3. xpi の中身を整える(build-drop.rb の前半)

_dist/<actor>/ の file(actor.json actor.mjs child.sys.mjs content.js manifest.json parent.sys.mjs)を作業 dir に写し:

4. 確かめる(固める前)

5. 固める(zip)

bytes を同じにするために:

cd 作業dir && TZ=UTC zip -q -X -D ../<actor>.xpi $(find . -type f | sed 's|^\./||' | sort)

6. manifest.json(drop の)

{
  "uuid": "<drop.toml の uuid>",
  "name": "<name>",
  "note": "...",
  "source": { "repo": "<registry の origin>", "commit": "<HEAD 40 桁>", "commit_time": "<UTC、分に丸め>", "path": "drops/<name>/src" },
  "entries": [ { "id": "<meta.id>", "name": "<actor>", "version": "<meta.version>.<日時>", "file": "<actor>.xpi", "sha256": "...", "size": N } ]
}

built_at のような「今」は入れない(入れると commit が同じでも bytes が変わる)。

7. 判(main だけ、CI)

contact(drop.toml)を manifest に写し、cosign sign-blob --yes --bundle manifest.json.sigstore.json manifest.json(keyless。 identity は https://github.com/f3liz-casa/noraneko-registry/.github/workflows/verify-and-sign.yml@refs/heads/main)。 attestations.json に Rekor の logIndex と run の URL。xpi と一緒に dl.f3liz.casa/drop/<uuid> へ(B2 の drops/<uuid>/)。

比べかた

mise exec -- ruby scripts/build.rb drops/<name>
shasum -a 256 _build/<name>/<actor>.xpi

CI の log(「registry の中で build する」の段)に同じ commit の manifest が出る。sha256version が一致すれば、 その xpi は誰が build しても同じ。ずれるなら疑う順: (1) commit が違う(PR の merge commit は別物)、(2) 道具の版(mise.toml)、 (3) _shared/ や tooling が違う、(4) zip の mtime / 並び / TZ。