Most apps should use the standard upload-assets workflow, which uploads your whole build on every commit. Incremental asset upload is an optimisation for extremely large builds where only a small fraction of files change between commits. It lets your CI pipeline upload only the chunks that changed, while still running tests against a complete build.
With incremental asset upload, you split your build into named chunks and upload each chunk to Meticulous independently. A chunk that hasn't changed since a previous build is already stored under its version id, so re-uploading it is a no-op. Once every chunk that was modified in the build has been uploaded, you trigger a test run by pointing Meticulous at a manifest that specifies the version to use for each chunk, so that Meticulous can re-assemble a full build.
This splits the work into two commands:
ci upload-asset-chunk— upload a single chunk (skipped instantly if already uploaded).ci run-with-uploaded-asset-chunks— assemble the referenced chunks and trigger a test run.
Meticulous downloads the referenced chunks into each test run, assembles them — using each chunk's directory prefix — into a single directory, serves that directory from an asset server, and runs your tests against that localhost URL.
Each chunk has:
chunkName— a logical name for the chunk (e.g.app,vendor,home-page-app). Chunks are deduped by the(chunkName, chunkVersionId)pair.chunkVersionId— a version identifier for the chunk's contents. See Choosing a version id.chunkAssetsDirectory— the local directory whose contents are packaged into the chunk.chunkAssetsDirectoryPrefix— an optional path prefix prepended to every file in the chunk. Files inchunkAssetsDirectoryare served under this prefix at replay time. Leave it empty to serve the files at the root.
When Meticulous assembles a build, it lays each chunk's files down under its prefix. For example, given:
- chunk1 — prefix
"", containsfile1.jsandsub-folder/file2.js - chunk2 — prefix
"", containsfile2.js - chunk3 — prefix
"sub-folder2", containsfile3.js
the assembled directory is:
file1.js
file2.js
sub-folder/file2.js
sub-folder2/file3.js
If two chunks contain a file at the same final path, the chunk later in the manifest wins — its copy of the file is served. Meticulous logs a warning for every collision and the CLI prints them, but the run still proceeds. Make sure your chunking scheme partitions files so that no two chunks produce the same final path.
It is a hard requirement that two chunks with different bytes never share the same version id — otherwise Meticulous may serve stale files and your test results will be incorrect.
It is a soft requirement that two chunks with identical bytes share the same version id — if this is violated too often you lose the deduplication benefit (the chunk is re-uploaded unnecessarily), but correctness is unaffected.
A version id can be:
- A hash of the chunk's contents (e.g. the directory's content hash), or
- A hash of the inputs to the build task that produced the chunk (e.g. the build cache key).
You'll need to provide Meticulous with a manfiest of the versions of all chunks to be used, not just the versions of the chunks modified in the build. For this reason using the hash of the inputs to the build task that produced the chunk (e.g. the build cache key) can work well, since for build tasks that have been skipped it's easier to compute the hash of the inputs than it is to compute the hash of the contents of the chunk (hash of the output).
For every chunk that makes up your build, call ci upload-asset-chunk:
npx @alwaysmeticulous/cli ci upload-asset-chunk \
--apiToken="$METICULOUS_API_TOKEN" \
--chunkName="home-page-app" \
--chunkVersionId="ad8a8da9aaaweaad9" \
--chunkAssetsDirectory="dist/home-page-app" \
--chunkAssetsDirectoryPrefix="" \
--commitSha="$CI_COMMIT_SHA"
If a chunk with the same chunkName and chunkVersionId is already uploaded, the command exits 0 after compressing but before uploading.
For the first build on your main branch you run you'll need to upload every chunk in your application. If you consistently run builds for every commit in the chain from thereon then you'll only need to upload the chunks that changed in that commit -- assuming that every ancestor commit successfully uploaded all of its changed chunks, all the way back to a commit where you uploaded every chunk. We therefore recommend either calling upload-asset-chunk for every commit, or using your build cache to skip chunk versions which you know for certain have already been uploaded, since there is already a cache entry from that build task.
For more information on the command run npx @alwaysmeticulous/cli ci upload-asset-chunk, or view the source code.
Once every chunk has been uploaded, write a manifest that references every chunk required to assemble the full build — not just the ones that changed in this commit — and pass it to ci run-with-uploaded-asset-chunks.
The manifest is a JSON array of { name, versionId } references:
cat > assets-manifest.json <<'EOF'
[
{ "name": "home-page-app", "versionId": "ad8a8da9aaaweaad9" },
{ "name": "settings-page-app", "versionId": "dd8ffdaa9dfedebb3" }
]
EOF
npx @alwaysmeticulous/cli ci run-with-uploaded-asset-chunks \
--apiToken="$METICULOUS_API_TOKEN" \
--assetReferencesManifest="./assets-manifest.json" \
--commitSha="$CI_COMMIT_SHA" \
--waitForBase
Because the version ids are required, your build needs to know — or be able to regenerate — the version id of every chunk that forms the build, including the unchanged ones. Using a content hash or build cache key as the version id (see Choosing a version id) makes this deterministic.
This command fails if any referenced chunk has not been fully uploaded, so make sure every chunk in the manifest has been uploaded (step 1) before triggering the run, and that your pipeline handles failed uploads.
To restrict the run to sessions starting on specific routes, pass --sessionFilter — see Filter Sessions by Start URL.
For more information on the command run npx @alwaysmeticulous/cli ci run-with-uploaded-asset-chunks, or view the source code.
The manifest is a non-empty JSON array with no duplicate chunk names. Each entry must be an object in one of two shapes:
{ "name": string, "versionId": string }— an explicit chunk version (both values non-empty), or{ "name": string, "versionLookup": "latest-in-history" }— Meticulous resolves the version from the base test run's history (see Version lookup for unchanged chunks).
[
{ "name": "charts-app", "versionId": "ad8a8da9aaaweaad9" },
{ "name": "plugin-1", "versionLookup": "latest-in-history" }
]
Names should be as stable as possible across builds.
Instead of computing and tracking version ids for chunks that haven't changed, you can reference them with { "name": "<chunk>", "versionLookup": "latest-in-history" }. When the test run is triggered, Meticulous walks the base test run and its ancestors (up to 16 levels) and resolves the lookup to the version that chunk had in the nearest ancestor test run whose manifest included it. Chunks with explicit versionIds are used as-is.
This means your CI only needs to compute version ids for (and upload) the chunks that changed in each commit; every unchanged chunk can be a one-line lookup entry.
Lookups are resolved once per upload, against the base of the first run created for it, and the resolved versions are then fixed for that upload. If several runs share the same uploaded chunk set but have different bases, they all serve the versions resolved for the first run — a single uploaded build serves a single set of chunk versions. Every subsequent run still re-checks that those chunks are still stored (see the retention requirement below). In the normal one-run-per-commit CI flow this is invisible; it only matters if you re-run the same upload against a different base.
Requirements:
- A base commit is required, since lookups are resolved from the base test run's history. For GitHub projects Meticulous infers the base automatically (the same base it would compare the run against), so you normally don't need to pass anything. Pass
--baseSha(or--repoDirectory, which infers it) only to override the base explicitly. - A prior chunked-asset test run must exist in the base commit's ancestry (within 16 levels) that referenced each looked-up chunk. For the first run you must upload every chunk with explicit version ids.
- The resolved chunk version must still be stored — if it was deleted by your data retention policy, the trigger fails with an error and you'll need to re-upload that chunk with an explicit version id.
- Keep
--waitForBaseenabled (the default). Lookups can only resolve once the base test run exists, so the trigger polls for it; running without a base is not possible for manifests with lookup entries.
It is a hard requirement that a chunk referenced with versionLookup is byte-for-byte unchanged relative to the base build. Meticulous cannot verify this: if the chunk actually changed, the stale version from the base lineage is served silently and your test results will be incorrect — the same class of failure as reusing a version id for different bytes.
run-with-uploaded-asset-chunks will print out a URL at which you can download the assembled build. Download it and check that the build has been re-assembled correctly, with the correct versions of the correct assets mounted in the correct folders. If you have a backend running at the URL hardcoded into the assets then you can also spin up a local server to serve the directory and check your app loads and runs correctly.
An example for a SvelteKit app;
#!/usr/bin/env bash
set -euo pipefail
# Build your app into per-chunk directories (e.g. dist/charts-app, dist/plugin-1).
#
# Note: some build outputs can vary slightly between builds even if the input
# source files are identical.
#
# For example some builds may embed the commit SHA in one of the built assets,
# or some builds may not be be fully deterministic. In the case of builds that
# embed information like a commit SHA you may want to make sure this is split out
# into a seperate chunk, rather than injected into every chunk. In the case
# of builds that are not fully deterministic (bytes can change slightly on a rebuild)
# you may want to use build caching on a stable cache key to ensure chunk version
# ids stay stable when the outputs are functionally identical.
yarn build
# Split the SvelteKit static build into two chunks along a natural boundary:
# - "html": root-level pages + static assets, served at /
# - "app": the hashed _app/ bundle, served under the _app prefix
rm -rf chunks
mkdir -p chunks/html
# Root-served files (everything except the _app bundle).
find build -maxdepth 1 -mindepth 1 ! -name _app \
-exec cp -R {} chunks/html/ \;
# The _app bundle is uploaded as its own chunk under the _app prefix.
# Version each chunk by a content checksum (SHA1 over relative path + bytes)
# so chunks dedupe across commits when their contents are unchanged.
hash_dir() {
( cd "$1" && find . -type f -print0 | sort -z | xargs -0 sha1sum ) | sha1sum | cut -d' ' -f1
}
HTML_VERSION=$(hash_dir chunks/html)
APP_VERSION=$(hash_dir build/_app)
# Upload the html chunk (short circuits if already uploaded)
npx -y @alwaysmeticulous/cli@latest ci upload-asset-chunk \
--chunkName=html \
--chunkVersionId="$HTML_VERSION" \
--chunkAssetsDirectory=chunks/html \
--commitSha="${{ github.sha }}"
# Upload the app chunk (short circuits if already uploaded)
npx -y @alwaysmeticulous/cli@latest ci upload-asset-chunk \
--chunkName=app \
--chunkVersionId="$APP_VERSION" \
--chunkAssetsDirectory=build/_app \
--chunkAssetsDirectoryPrefix=_app \
--commitSha="${{ github.sha }}"
# Trigger the run
cat > manifest.json <<JSON
[
{ "name": "html", "versionId": "$HTML_VERSION" },
{ "name": "app", "versionId": "$APP_VERSION" }
]
JSON
npx -y @alwaysmeticulous/cli@latest ci run-with-uploaded-asset-chunks \
--commitSha="${{ github.sha }}" \
--assetReferencesManifest=manifest.json \
--waitForBase=true
Meticulous persists uploaded chunks so they can be reused across builds. A chunk is retained as long as it has been used by a test run in the last N days, even if it was uploaded more than N days ago. N is set to match the data retention period you have configured for your project (default ~90 days).
- Split your build into named chunks and give each a version id derived from its contents or build inputs.
- Upload at least the changed chunk on each commit with
ci upload-asset-chunk. - Reference every chunk required for the full build in the manifest — with an explicit
versionIdfor changed chunks, orversionLookup: "latest-in-history"for unchanged ones — then trigger the run withci run-with-uploaded-asset-chunks.