Runtime dependency-graph pinning¶
AutoMobile publishes a pinned runtime dependency graph so that a clean
bun install -g @kaeawc/auto-mobile@<version> resolves the same versions no
matter what compatible releases appear in the registry afterward (issue #5421).
The problem it solves¶
bun install -g re-resolves the published dependencies from their version
ranges at install time. With caret ranges, an unchanged release can start
selecting dependency versions that did not exist when it was published. On
2026-08-20 a staged @peculiar/asn1-*@2.9.4 publish made clean installs of a
fixed @kaeawc/auto-mobile version fail transiently with
No version matching "^2.9.4" found until every matching version became
resolvable. A fixed release must resolve a fixed graph.
Why the graph is small¶
build.ts bundles the server into a single dist/src/index.js, externalizing
only the image backends it loads from node_modules at runtime — the jimp
family (jimp, @jimp/core) and the sharp family (sharp + the platform
@img/sharp-* binaries). One more package is a runtime dependency without being
in that bundle: kysely. Migrations are bundled through static imports.
kysely is kept as a runtime root conservatively: kysely is bundled, but an
AUTOMOBILE_MIGRATIONS_DIR override loads raw migrations that import kysely’s
sql tag, and such a folder resolves it from its own location. Dropping the root
is a separate runtime-graph change.
The server uses the zod/v4 subpath exported by zod@3.25.76. It remains
bundled into dist/, while the exact root package is also published so every
Jimp zod: ^3.23.8 edge resolves to the same pinned version. Every other
inlined package (werift, the MCP SDK, …) is not needed at install time and
lives in devDependencies; consumers never install them, which is what removed
the @peculiar/asn1-* install path entirely.
How the graph is pinned¶
The primary mechanism Bun honors for a consumer’s bun install -g is exact
top-level dependencies (verified empirically). A published
npm-shrinkwrap.json is ignored by Bun. The graph is therefore flattened into
exact dependencies wherever a build-time version does not conflict, with a
small bundledDependencies set for the conflicting Jimp paths:
- runtime roots —
jimp,@jimp/core,sharp,kysely— and every pure-transitive node of their closure are pinned to exact versions; - shared Jimp validator —
zod@3.25.76is a direct exact pin. AutoMobile imports itszod/v4subpath, so the bundled server retains the v4 API while Jimp’s v3-compatible ranges cannot re-resolve; - platform-native
@img/sharp-*binaries stay inoptionalDependencies(already exact-pinned, resolved per platform); - non-native
@imgtransitives, including sharp’s@img/colour, remain independenciesand are exact-pinned like every other pure-transitive node; @jimp/diff,@jimp/js-png, andparse-bmfont-xmlare bundled so their nestedpixelmatch,pngjs, andxml2jsversions remain part of the published artifact instead of being resolved from later registry releases. The clean-room gate verifies each bundled owner path, including nested dependency paths, not merely that one matching residual exists somewhere in the package. The native@img/sharp-*binaries remain unbundled and platform-selected.
The selected bundle adds about 17 MiB to the package. Source maps do not embed sources by default
(opt out locally with AUTOMOBILE_SOURCEMAP_STRIP_SOURCES=false bun run build) and external source
maps are excluded from the tarball (unused at runtime). The unpacked-size guard runs on the trimmed
pack and is capped at 20,132,659 bytes (16 MiB + 20%; #9571, #12457); a pack with source maps restored
(23,061,125 bytes) fails, and an untrimmed pack is larger still.
The pinned graph is mirrored in scripts/release/runtime-graph.json (the
manifest) and enforced by:
| Guard | Where | What it proves |
|---|---|---|
pin-runtime-deps.ts --check |
Fast Validation (runtime-pins) |
package.json + manifest are in lock-step with bun.lock (hermetic) |
verify-pinned-runtime-graph.sh |
PR Node Checks job + release preflight | a clean-cache install of the trimmed packed artifact reproduces every runtime version and imports each importable bundled package |
CI pack trimming¶
The prepack hook moves unused sources, declarations, maps, tests, docs and verified browser-only files out of the bundled production dependency closure. Runtime entry points, wildcard export prefixes, package metadata, README and license files stay intact. Postpack restores the original bytes; the next enabled prepack also restores any backup left by an interrupted pack.
AUTOMOBILE_TRIM_BUNDLED_DEPS=true enables trimming; false disables it.
Otherwise trimming runs only with CI=true or CI=1. Local npm pack is
untrimmed by default. The unpacked-size benchmark and clean-room graph gate
explicitly enable trimming, including locally, so they enforce the same trimmed
cap and runtime imports as CI. The import smoke resolves each dependency by name
beside its own installed directory under Bun’s export conditions, including
nested duplicate versions. Packages with no runtime entry or only a bin and no
importable entry are skipped by metadata/file rules, and every skip is printed.
AutoMobile’s own server entry point is never imported. All hook diagnostics go
to stderr to preserve pack JSON.
Refreshing the graph (dependency / security updates)¶
Automatic refresh on Dependabot PRs¶
.github/workflows/dependabot-runtime-pins.yml handles opened, synchronized,
and reopened Dependabot PRs touching package.json or bun.lock. Both jobs
require the dependabot[bot] actor and a head repository matching this repository.
They use read-only permissions, bounded timeouts, and checkouts that disable LFS
and persist no credentials. All actions are pinned to full commit SHAs.
The regenerate job checks out the exact PR head SHA, installs, builds, runs
pin-runtime-deps.ts --write, installs again, formats, and runs --check before uploading an
artifact holding only package.json, bun.lock and scripts/release/runtime-graph.json (repo-relative
paths; name includes PR number, run ID and attempt; one-day retention). It receives no secrets. A
failed check fails the job, so no artifact is uploaded and the push job does not run.
The split exists because lifecycle scripts and dependency code on the PR runner can rewrite the
commit script, plant Git hooks, change Git config, shadow commands via PATH/GITHUB_PATH, or
poison later steps via GITHUB_ENV (BASH_ENV, loader variables); a secret in that same job would
be exposed to the tampered environment.
The push job uses a fresh runner, never executes PR code, and restores no cache. It sparse-checks
out the trusted commit script from the default branch and downloads only the named artifact. Only
the final script step receives AUTO_MOBILE_PR_TOKEN. The script:
- validates the artifact as data: exactly three regular files plus needed directories, no symlinks,
hardlinks, extra paths or dotfiles, a 20 MiB cap per file, and one JSON object each for the package
and graph manifests (parsed with
jq); - builds a temporary repository with templates disabled, global/system config ignored, and hooks, filesystem monitors, external protocol helpers, credential helpers and signing disabled on every Git call;
- fetches only the PR branch and verifies its head still equals the starting SHA;
- populates only the index (never the PR working tree), staging exact bytes with
hash-object --no-filtersandupdate-index --cacheinfoto bypass attribute filters and encoding conversions; directory conflicts fail without pushing; - commits as
github-actions[bot]and does a normal fast-forward push (concurrent updates are rejected; no changes is a successful notice); - masks the raw PAT and its base64 form, authenticates via an HTTP extraheader through
GIT_CONFIG_COUNTenv entries (never a URL, argument or persisted config), and removes the temp repo on exit.
The trigger is pull_request, not pull_request_target: dependency code runs
with read-only permissions rather than a privileged base-repository context.
The separate runner and trusted default-branch script isolate the PAT from that
code. The PAT remains a repository-write credential. A malicious change to the
three files can still be pushed as data: these are the dependency changes
Dependabot proposed plus regenerated pins, and normal review and CI still apply.
Artifact validation is structural only; it does not establish that the dependency
content or regenerated pins are safe or semantically correct. The regenerate
runner can tamper with its own check and artifact, so its check is a correctness
gate, not a security attestation. The default branch and pinned actions remain
part of the trusted boundary.
Owner setup: configure the existing AUTO_MOBILE_PR_TOKEN PAT in this repository’s Dependabot
secrets store too, with branch push access (Dependabot pull_request runs receive Dependabot
secrets, not Actions secrets). Missing configuration errors clearly even when the artifact has no
changes. The trusted script must already be on the default branch. The default GITHUB_TOKEN is
read-only here and its pushes would not retrigger CI; the PAT follow-up commit retriggers
pull_request CI with fresh required checks, and its actor (the PAT owner) means the Dependabot-only
guard prevents a loop. No skip-CI marker is used. A human/PAT push makes Dependabot stop auto-rebasing
that PR; use @dependabot recreate to regenerate.
Real version conflicts still need human intervention, as do coordinated
sharp/@img updates ignored by .github/dependabot.yml. There is no new
nested-lockfile-entry pruner: although bun install runs, it may retain a stale
redundant nested entry. For example, #8330 retained
file-type/uint8array-extras@1.5.0 after hoisting uint8array-extras@1.6.0.
If regeneration or --check reports
Residual runtime dependency owners are not bundled: file-type, the job fails
loudly and a human must prune the redundant entry and refresh the graph.
Manual refresh¶
When a runtime dependency or a security override changes the resolved graph
(e.g. a Dependabot bump to jimp or one of their transitives, or a manual
sharp bump):
sharpand every@img/*entry are ignored in.github/dependabot.ymland must be bumped by hand. Dependabot bumps only the entries eligible when it opens the PR, which splits sharp’s 24-entry native matrix (PR #6820 left six behind and sharp stopped loading on linux-x64), and it cannot regeneratescripts/release/runtime-graph.json. Bumpsharptogether with every@img/sharp-*/@img/sharp-libvips-*pin, then follow the steps below;scripts/check-sharp-matrix-coherence.tsrejects a partial bump.
- Update the version(s) as usual and run
bun installsobun.lockreflects the new resolution. - Rebuild so the roots derivation reads the current bundle:
bun run build - Regenerate the pinned graph and manifest:
bun scripts/release/pin-runtime-deps.ts --write bun install # refresh bun.lock for any newly-direct pins - Commit
package.json,bun.lock, andscripts/release/runtime-graph.jsontogether. - Confirm locally before pushing:
bun scripts/release/pin-runtime-deps.ts --check bash scripts/ci/verify-pinned-runtime-graph.sh
If the clean-room gate reddens, regenerate the manifest and inspect the packed tree before publishing. A bundled or exact runtime version changing without that refresh is a release failure, not an expected registry update.