Update the matched render toolchain
This procedure expects ghapp and python3, and operates on
tools/deps/manifest.json, tools/scripts/fetch_skia_for_release.py, and
tools/scripts/fetch_v8_for_release.py.
Pulp's default is a milestone-matched, provider-validated set: the published
skia-builder release, the Dawn revision that Skia itself built from, and the V8
revision from the same Chromium milestone branch. Chromium's raw Skia/Dawn DEPS pins
may differ from the later published Skia branch head; that is valid only when the
v8-builder release records both generations, binds built_skia/built_dawn to the
published provider, and passes the combined provider-identity/ODR gate. v8-builder may also publish
newer weekly LKGR releases; those remain valid opt-in V8 choices, but they are not the
default pin for a milestone update.
Truth model
Keep these values distinct in notes and manifests:
skia: exact commit at refs/heads/chrome/mNNN used by skia-builder.
built_dawn: Dawn from that Skia commit's own DEPS; this is the Dawn actually
compiled into the Skia/Dawn artifacts.
v8: V8 from Chromium refs/branch-heads/<branch> DEPS for milestone NNN, after
recording Chromium's raw Skia revision separately from the provider generation
validated by the matched release.
dawn: Chromium's Dawn pin. It can differ from built_dawn; record the mismatch.
Do not claim all three are one identical Chromium DEPS tuple when it differs.
v8-builder/tools/milestone_pin.py resolves and enforces this contract. For example:
python3 ../v8-builder/tools/milestone_pin.py 153 \
--skia-release-tag chrome/m153 > /tmp/m153-render-lock.json
python3 -m json.tool /tmp/m153-render-lock.json
The result must have the requested milestone, skia_release_tag, exact Chromium
skia/v8/dawn SHAs, and exact built_skia/built_dawn provider SHAs. False
skia_matches_chromium or dawn_matches_chromium values are disclosed provenance,
not failures: acceptance instead requires validated_skia_release to equal the
active Skia version, built_skia/built_dawn to equal Pulp's active provider, every
asset's embedded pair to agree, and the combined provider-identity/ODR gate to pass.
Release lanes
skia-builder publishes chrome/mNNN, then dispatches
skia_milestone_published to v8-builder.
- v8-builder's
matched-milestone.yml resolves the lock, skips an exact release that
already exists, and dispatches the sealed all-platform build with publication on.
- v8-builder's
release-watch.yml continues its weekly LKGR cadence independently.
For a historical Skia release or recovery, manually start the same idempotent lane:
ghapp workflow run matched-milestone.yml \
--repo danielraffel/v8-builder \
-f milestone=153 \
-f skia_release_tag=chrome/m153
Do not update Pulp's V8 pin until that matched release is published and every requested
platform asset is present. Never substitute the newest weekly V8 merely because it is
newer.
Matched release collection
Present the Skia/Dawn and V8 release URLs together as the milestone collection, then
download only the assets the user needs:
ghapp release view chrome/m153 --repo danielraffel/skia-builder --json url
ghapp release list --repo danielraffel/v8-builder --limit 100 --json tagName \
--jq '.[] | select(.tagName | startswith("v8-m153-")) |
"https://github.com/danielraffel/v8-builder/releases/tag/\(.tagName)"'
The collection is a provenance and compatibility convenience, not one combined archive:
Skia/Dawn, V8, or both may be consumed independently.
Independent Skia/Dawn advance
When the user explicitly asks to advance Skia/Dawn before the matching V8 build
is complete, keep the lanes separate instead of blocking the render update or
claiming a matched tuple:
- Update the active Skia entry and its built-Dawn provenance from published
assets. Leave the V8 version, assets, and its internal
paired_* metadata at
their last verified milestone.
- State the mixed active-provider selection in
DEPENDENCIES.md and the V8
manifest notes. Do not rewrite V8's historical skia_release_tag,
paired_skia, or paired_dawn to the newer active Skia values.
- Run the sealed V8 provider-identity/ODR gate against the new Skia/Dawn
provider. Keep this in the required release-path CI surface while the mixed
selection is active; a successful one-off local or Skia-only build does not
prove mixed-provider safety.
- Record a precise follow-up trigger: adopt V8 only after the matched release
contains every required platform asset and its embedded pair manifest passes
the normal milestone checks.
This is a bounded compatibility state, not a new default release policy. Return
to a fully milestone-matched selection as soon as the verified V8 release is
available.
Pulp update checklist
For the canonical executable validation and machine-readable result, run:
python3 tools/deps/validate_hosts.py --render-toolchain
The local native arm64 leg performs the full mixed-provider proof. Configured
Unix remotes populate and verify their own immutable generation, run the
capability probe, and require the second fetch to be a no-download hit. The
underlying validate_render_update.py JSON records platform, asset SHA,
generation receipt, capability result, cache result, and mixed-provider result;
retain those fields in the PR/landing evidence.
- Work from current
origin/main in a clean worktree. Read release notes from M+1
through the target and search Pulp for removed APIs.
- Inspect the published Skia release with
ghapp release view chrome/mNNN --repo danielraffel/skia-builder --json assets,publishedAt. Update every platform URL and
SHA-256 in tools/deps/manifest.json; do not omit Windows, Linux ARM64, Apple device
and simulator slices, WASM, or XCFramework coverage.
- Inspect one native archive and
external/skia-build/VERSION.md. Confirm the exact
Skia and built-Dawn SHAs, deployment floors, and optional archives such as Skottie,
jsonreader, and skresources. For Windows ARM64EC and ARM64X consumers, also
verify that the producer archive contains the matching
build/win-gpu/lib/Release/arm64ec/ or arm64x/ slice; pure arm64/ and flat
x64 libraries are not compatible substitutes. Record the exact producer run and
archive digest, and keep the consumer provider decision fail-closed when the
requested slice is missing.
- Inspect the matched v8-builder release manifests. All assets must agree on
pair.pair_kind=chromium-milestone, pair.milestone, pair.skia, pair.v8,
pair.built_dawn, and pair.validated_skia_release.
- Update the V8 entry, all asset URLs/hashes,
DEPENDENCIES.md, V8 provider comments,
tools/cmake/FindV8.cmake, tools/cmake/PulpV8Windows.cmake, test fixtures, and
tools/deps/min_os.json only from measured release facts.
- Update the Skia/Dawn mirrors:
external/skia-build/VERSION.md, visual-harness pins,
build-script defaults, docs/support matrix, and manifest fixtures.
- Run the manifest mirror/audit tests and both fetch-script suites. Fetch a real native
Skia asset and matched V8 asset, configure with GPU + Lottie + V8, and run the
provider-identity/ODR validation. A pixel-only test is insufficient.
Configure that validation lane with
PULP_VALIDATE_CAPTURE_STRICT=ON
alongside PULP_VALIDATE_V8_PROVIDER_STRICT=ON. The default Three.js native
demo capture tests tolerate a build without V8, or a host without a native
Dawn adapter, as a skipped PNG assertion, so a toolchain pin that quietly
broke the V8 link or the Dawn adapter still shows them green. The strict
variants turn both skips into failures, which is what you want from the one
lane whose job is to prove the new pins actually render.
For m153+, run python3 tools/scripts/verify_skia_m153_capabilities.py --platform <matching-native-desktop-platform> --skia-dir <materialized-generation>. Run each architecture on its matching host; the
compile-and-execute probe intentionally rejects mobile, WASM, Windows, and
cross-architecture assets. The
probe must reject a directory unless its verified asset stamp matches that
platform's manifest digest and both Skia and Dawn archives are materialized.
It proves the new API and exported-symbol surface against the exact provider;
actual Graphite executor dispatch belongs to the integration's measured
behavior gate, not this toolchain probe.
A darwin-universal provider is the deliberate aggregate exception: run it
on darwin-arm64 so the probe compile/links/runs arm64 natively and x86_64
explicitly through Rosetta. Its single JSON result binds both records to the
same universal asset digest, generation receipt, probe source, and Pulp
source SHA. A universal build/lipo check without that dual-slice receipt is
not m153 capability evidence.
- Measure every Apple slice actually selected by the manifest. A same-tag asset can
leak a higher deployment target than its universal sibling. Measure the exact
thin archive when the manifest selects a thin archive; evidence from a
universal sibling is not interchangeable. Use the verified selected asset or
rebuild rather than publishing a false minimum-OS claim.
- Re-bake CI goldens when either prebuilt pin changes, then ship only after required CI
and cross-platform asset coverage are green.
- Exercise the shared-cache publication path with
--cache-lock-timeout: a pin-stale or cold cache must be populated only by
the lock owner in a private sibling staging directory, then renamed into an
immutable platform-plus-asset-SHA-plus-receipt-schema generation. A waiter must recheck the
winner's exact stamp plus platform library before it skips downloading, and
a pin bump must preserve the prior generation for bound consumers. Never
seed the canonical cache by rsyncing a checkout merely because
external/skia-build/build exists. Keep release x64/universal destinations
isolated from the host arm64 cache.
- Revalidate the independently pinned three.js runtime used by native WebGPU
consumers. When
PULP_ENABLE_THREEJS_RUNTIME=ON, Pulp fetches or accepts
PULP_THREEJS_RUNTIME_DIR and stages the verified payload under
share/pulp/threejs; it is not test-only. A render-toolchain change that
alters this compatibility or install boundary must update the dependency
manifest, attribution surfaces, runtime manifest, and installed-SDK proof.
- After merge, prewarm every active build host through the fetcher's normal
immutable cache-owner path. For each M3, M5, M1, Mac mini, and Mac Pro host,
record the exact asset SHA, materialized
libskia.a plus
libdawn_combined.a, and a second invocation that reports the complete
generation and performs no download. Skip or defer a host only with an
explicit offline/retired disposition; never copy a checkout cache between
machines.
- Treat missing provenance as a cold cache. A materialized library plus a
tracked
VERSION.md digest is not proof that those bytes came from that
archive: without the exact fetcher-written asset stamp, re-download and
verify before publication. Source fallbacks must likewise pin the builder
revision and fail before copying output unless the built Skia checkout HEAD
equals the manifest's immutable skia_commit; a milestone branch name alone
is never sufficient provenance.
- Treat any provider release marked
release_immutable: false as mutable until
its publishing workflow is terminal. Record the exact publisher run and
release updated_at, collect every asset ID/digest plus metadata and pair
digests, then re-query the same release immediately before publication. If
any asset, metadata, pair field, or release timestamp changed, invalidate the
prior validation, repin the complete platform set, and rerun the provider and
cache gates. A successful earlier fetch proves only the bytes that existed at
that time; it does not authorize landing after the release was replaced.
Common traps
-
On Windows ARM64, a Ninja build using the ARM-hosted x64 MSVC compiler has
an empty CMAKE_GENERATOR_PLATFORM and an ARM64 host processor. Choose the
Skia release/cache key and wgpu-native asset from
pulp_windows_target_arch(), which reads the compiler target. The x64 Skia
release may place libraries under build/win-gpu/lib/Release/x64/; older
releases can be flat. Test a fresh x64 configure on ARM64 without an explicit
SKIA_DIR as well as an explicit nested archive.
-
The Vellum closure gate identifies Skia inputs by determinism.skia_commit and
by the asset digest sealed in the cache generation's
.skia-generation-manifest.json, which must be one of
determinism.release_assets[*].sha256. Each closure file must match its digest
in that receipt. Bump skia_commit and the asset digests together, or every
Skia row in the closure goes unresolved.
-
release-path-pr-gate.yml's darwin runner resolver runs
resolve_release_runners.py --apply-class-label pulp-release-pr-gate after a
sparse checkout. With PULP_RELEASE_CLASS_TOKENS unset the selector is
verbatim; set, a self-hosted selector gains pulp-release-pr-gate, so a gate
run that never starts may be an unserved class, not a toolchain failure.
-
A Skia milestone name alone does not select a V8 revision. Resolve through Chromium's
milestone branch, preserve its raw Skia SHA, and separately prove the release's
validated built_skia/built_dawn pair equals Pulp's active provider.
-
Skia's Dawn pin and Chromium's Dawn pin are separate dependency surfaces.
-
The SDK's share/pulp/runtime-pins.json (embedded in every bundle's
pulp-build-info.json) is DERIVED, not edited: PulpRuntimePins.cmake parses
**Release:**, the "Skia branch tip is <sha>" sentence, and
**skia-builder ref:** out of VERSION.md, decodes kDawnVersion from the
linked dawn/dawn_version.h, and takes wgpu-native from the single
PULP_WGPU_NATIVE_VERSION in PulpDependencies.cmake. Rewording those
VERSION.md lines turns the pins into null; the
cmake-bundle-build-info-contract ctest compares them with the manifest's
Skia version and Dawn notes (DEPS file at <sha>, Dawn SHA1 <sha>), so keep
those phrasings too. A wgpu-native bump edits that one variable,
WGPU_NATIVE_VERSION in setup.sh, shared-source-contract.txt, and the
wgpu-native row of tools/deps/manifest.json: its version and every
artifacts entry's sha256 (the zip) and tree_sha256 (the extracted tree).
Download each asset into a new empty directory and run
python3 -I tools/deps/wgpu_native_digests.py <dir>/*.zip. Configure and
setup.sh refuse any archive whose digest differs, and configure re-checks
the shared cache against tree_sha256, so a missed digest fails loudly
rather than fetching unpinned bytes.
-
PulpDependencies.cmake sets CMAKE_DISABLE_PRECOMPILE_HEADERS ON around
SDL3's FetchContent_MakeAvailable only. SDL3 precompiles src/SDL_internal.h
into every object, and ccache refuses to cache a TU compiled against a PCH
unless pch_defines sloppiness is on (measured on 3.2.12: 188 of 214 SDL3
calls "could not use precompiled header"). Keep the scope tight when bumping
SDL3: the save/restore of the previous value is what keeps a later PCH user
(the Catch2 test carriers in PulpTestSuite.cmake) unaffected. The
test-pch-wiring ctest asserts SDL3-static compiles with no PCH.
-
GitHub release tags containing / must remain correctly URL-encoded/handled.
-
Linux x64 Skia and V8 assets must retain the portable glibc floor; do not replace
their portable releases with a normal ubuntu-latest artifact.
-
fetch_skia_for_release.py platform keys must match the manifest exactly (notably
wasm-wasm32).
-
After a Skia pin bump, the gate VMs download the new archive until each tartci
host's artifact cache holds it (scripts/artifact-cache.sh add --url <asset> --sha256 <pin> in tartci). That is a speed loss only: the fetcher prefers
$TARTCI_ARTIFACT_CACHE/sha256/<pin> and falls back to the network on a miss.
-
fetch_skia_for_release.py retries the asset download, but only for failures a
second attempt can fix: 408/425/429 and 5xx, plus URLError, TimeoutError,
ConnectionError and IncompleteRead, with exponential backoff from 2s capped at
30s and a numeric Retry-After taking precedence. A 403/404 raises immediately,
because at this stage that means the pin names an asset that is not there, and
spending the backoff first buries that error under minutes of silence. A bare
OSError is deliberately not transient either: it is what a full disk raises on
the write side, and retrying re-downloads hundreds of megabytes to fill the same
disk. When a pin bump fails here, read which class it was before assuming the
network.
-
Keep release-fetch progress output ASCII-safe. Windows release runners can use a
cp1252 console, where decorative Unicode arrows raise UnicodeEncodeError before
an asset download starts; exercise the full Windows fetch path with cp1252 stdout.
-
Compare extracted-generation receipts in a host-independent canonical path order.
Windows Path ordering is case-insensitive while ZIP member names use POSIX,
case-sensitive ordering; directly comparing those sorted lists can reject an
otherwise byte-identical authenticated archive (for example SkBlendMode.h and
SkBlender.h). Keep path/hash/size integrity checks exact, but canonicalize both
projections by their serialized archive path before comparing them.
-
JS-engine wording in tools/deps/manifest.json, tools/deps/min_os.json, and
tools/cmake/FindV8.cmake describes a selection contract, not just prose.
The contract is: auto/quickjs compile QuickJS only; jsc additionally
compiles core/view/src/js_jsc_engine.mm and links
JavaScriptCore.framework on Apple; v8 selects the sealed prebuilt. JSC is
opt-in, never implied by "Apple". Older text said "default is QuickJS, JSC
on Apple", which reads as JSC being automatic on Apple platforms and is wrong.
Likewise iOS is no longer "JSC-only": Pulp has no device/AUv3 V8 runtime
acceptance or packaging contract. The m153 V8 release includes a jitless
simulator framework only for provider/header provenance validation; it is not
selectable as an iOS runtime. QuickJS is the default and JSC stays opt-in.
When a pin or min-OS note is edited, keep these three files saying the same
thing — they are the only place the engine contract is written down outside
the CMake modules.
-
Matched V8 cold-fetch validation resolves the immutable builder tag through
the GitHub API. CI callers must expose their read-only ${{ github.token }}
as GH_TOKEN; otherwise a valid sealed asset can fail after download when
the unauthenticated API budget is exhausted. The fetcher deliberately sends
that credential only to https://api.github.com and refuses redirects; it is
never forwarded to release-asset or cross-origin targets.
-
A source dependency pinned by per-file SHA-256 must be checked out with EOL
conversion off, or Windows breaks it before anything builds. Git for Windows
defaults to core.autocrlf=true, so every LF becomes CRLF on checkout, every
digest in tools/cmake/threejs-runtime-manifest.json mismatches, and
PulpDependencies.cmake hard-fails with "Three.js runtime file does not match
pinned revision". Three.js opts in by passing verbatim-eol to
ensure_shared_git_source in setup.sh, which expands to
-c core.autocrlf=false -c core.eol=lf on the clone and pins both keys
repo-locally; the FetchContent_Declare carries a matching GIT_CONFIG for
the no-shared-cache path. Never fix this by normalizing bytes before hashing —
the digest's whole job is to prove the shipped files are upstream's exact
bytes. Any future digest-pinned source dependency needs the same opt-in.
Note that GIT_CONFIG on FetchContent_Declare alone is not enough on the
release path: pulp_register_fetchcontent_source short-circuits FetchContent
whenever the shared cache exists, and the release leg primes that cache with
./setup.sh --ci --deps-only first.
-
Repairing an already-converted cache with git checkout --force -- . silently
does nothing. Git decides a file is current from the index's cached stat data
and never reads the bytes, and the bad checkout recorded its own stat when it
was written; --force means "overwrite local modifications", and by the stat
cache there are none. git checkout-index -a -f and git read-tree --reset -u
fail the same way. The primitive that works is dropping .git/index and then
git reset --hard (gitattributes(5)'s own renormalisation recipe), which needs
no network even on a --filter=blob:none partial clone. This trap is not
specific to line endings — any repair phrased as "check the worktree out again"
hits it. A test written the obvious way (poison, immediately repair, assert)
will certify the broken primitive, because git distrusts a stat inside its own
timestamp granularity and falls back to comparing content; settle the fixture
with touch -t 202001010000 plus git update-index --refresh or the test
grades its own homework.
-
The visual harness has two raster pins on two different version lines, and
"re-bake CI goldens" only means one of them. The C++ Skia archive rasterizes
nothing in tools/harness/visual/: its committed PNG golden is produced by the
skia-python wheel, pinned separately as determinism.skia_python_smoke_version
in tools/deps/manifest.json (mirrored into pins.SKIA_PYTHON_SMOKE_VERSION
and the Dockerfile ARG, cross-checked by check_skia_pin.py). That wheel
deliberately trails the C++ milestone, so a Skia/Dawn milestone bump leaves the
PNG golden and pins.RASTER_GOLDEN_SHA256 correct and untouched, while bumping
only the wheel invalidates both without moving a single release-asset digest.
When changing the wheel, regenerate through
python3 -m tools.harness.visual.runner --generate --all --surface canvas2d
and update the recorded sha256 in the same commit: a golden regenerated without
its digest fails tests/test_raster_golden.py before any raster runs.
pins.RASTER_GOLDEN_VERIFIED_PLATFORMS records which hosts that identity was
actually measured on, so add a platform key only after a run on that platform
reported the matching digest.
-
The design importer browser runtime is split across
bin/browser_capture-v1 and bin/jsx-runtime. Release workflow tests must
stage and verify both roots because capture.mjs resolves the materialized
binding contract through the sibling path. Keep the source module canonical
under tools/import-design/jsx-runtime and test the missing sibling as a
failure.
Windows GPU slices
For Windows GPU consumers, record separate windows-x64 and windows-arm64
release assets in the manifest. Select the slice from the CMake target
architecture (CMAKE_GENERATOR_PLATFORM when cross-compiling) and mirror each
release digest in external/skia-build/VERSION.md before running the fetcher and
host validation gates.
The shared Windows Skia cache contains target-selected skia.lib, not Unix
libskia.a. The x64 fetcher can publish a flat build/win-gpu/lib/Release/
layout, while ARM64 slices use architecture subdirectories. After auto-fetch,
resolve the archive with pulp_windows_local_skia so the same target-aware
selection applies to checkout-local and shared-cache providers.
Even a published fork release can be clobbered. A Windows backfill lane
re-uploads its slice with gh release upload --clobber under the existing
chrome/mNNN tag, so the pinned digest stops matching and the release CLI
fails at fetch_skia_for_release.py <platform> with sha256 mismatch. Because
the release job needs the whole build matrix, every platform's release stops
publishing. Do not bump the digest blindly. Recover the superseded bytes from
the fork's Actions artifact for the run that produced the old pin, unzip both,
and compare the libraries member by member, ignoring COFF TimeDateStamp. A
rebuild of the same source that differs only in timestamps can be repinned as
is, and its provenance belongs in VERSION.md. Any other content difference is
a toolchain change and needs a full Windows build and smoke before the pin
moves.