Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5815520
feat(cache): add the cache provider contract and tier coordination
janicduplessis Aug 31, 2026
7d4b0bf
feat(cache): resolve project cache providers from settings
janicduplessis Aug 31, 2026
556b3e9
feat(metro): read the project cache provider after a local miss
janicduplessis Aug 31, 2026
a30fe4c
feat(cache): expose the local build cache as a provider capability
janicduplessis Aug 31, 2026
8c41fc9
feat(cache): load the build provider lazily after a local miss
janicduplessis Aug 31, 2026
7152336
feat(ios): read and write the project cache provider before Expo
janicduplessis Aug 31, 2026
f190ae7
feat(android): read and write the project cache provider before Expo
janicduplessis Aug 31, 2026
78e3e8a
docs(cache): document the provider tier and publish the package
janicduplessis Aug 31, 2026
628cddc
docs(cache): list the provider settings in the reference table
janicduplessis Aug 31, 2026
d3ee517
fix(cache): bound provider loading and keep the deadline timer armed
janicduplessis Aug 31, 2026
26aa62f
fix(metro): cap provider reads and switch the tier off after repeated…
janicduplessis Aug 31, 2026
c1f7a46
fix(metro): stop the committed provider search at the repository root
janicduplessis Aug 31, 2026
3ec194d
fix(cli): report an invalid cache.provider setting instead of ignorin…
janicduplessis Aug 31, 2026
89acd70
feat(cache): pin the destination and overwrite contract, tune budgets…
janicduplessis Aug 31, 2026
e023621
fix(cli): name the project provider in the run summary
janicduplessis Aug 31, 2026
f4771a7
fix(cache): create the provider download directory only when it is used
janicduplessis Aug 31, 2026
3729a0a
docs(cache): state the Metro seam, the executable-code risk, and the …
janicduplessis Aug 31, 2026
e379939
fix(metro): drop queued provider uploads once the breaker trips
janicduplessis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Fast, BLOCKING CI. Runs on every push and pull request.
#
# Everything provable on a plain Linux box with no Xcode and no Android SDK:
# lint, format, typecheck, build, the full vitest unit suite (all four
# lint, format, typecheck, build, the full vitest unit suite (all five
# packages), and the fast cross-platform e2e (test/e2e/cache-flow.e2e.js), which
# drives the real CLI and the real cache library end to end with no compiler.
# The SLOW native builds live in e2e-native.yml, gated so a flaky 15-minute
Expand Down Expand Up @@ -72,8 +72,8 @@ jobs:
- name: Dead code (knip)
run: pnpm run knip

# The unit suite on vitest (rolldown-vite): stim-cli + both cache packages,
# 52 files. Real processes, real ports, real git; fakes for xcrun/adb.
# The unit suite on vitest (rolldown-vite): stim-cli + the cache packages,
# 70 files. Real processes, real ports, real git; fakes for xcrun/adb.
- name: Unit tests (vitest)
run: pnpm test

Expand Down
15 changes: 12 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Publishes the four packages to npm via OIDC trusted publishing when a
# Publishes the five packages to npm via OIDC trusted publishing when a
# version tag is pushed. There is NO token: npm trusts a short-lived OIDC
# credential minted for exactly this repo + workflow + environment, and the
# `release` environment requires a manual approval, which replaces the OTP.
Expand Down Expand Up @@ -44,7 +44,7 @@ jobs:
- name: Tag matches the package versions
run: |
tag="${GITHUB_REF_NAME#v}"
for p in core stim-cli expo-build-cache metro; do
for p in core cache stim-cli expo-build-cache metro; do
v=$(node -p "require('./packages/$p/package.json').version")
if [ "$v" != "$tag" ]; then
echo "packages/$p is $v but the tag is $tag"; exit 1
Expand All @@ -59,6 +59,15 @@ jobs:
else
npm publish --provenance --access public --tag latest
fi
- name: Publish @stim-cli/cache
working-directory: packages/cache
run: |
version=$(node -p "require('./package.json').version")
if npm view "@stim-cli/cache@$version" version >/dev/null 2>&1; then
echo "@stim-cli/cache@$version is already published"
else
npm publish --provenance --access public --tag latest
fi
- name: Publish @stim-cli/metro
working-directory: packages/metro
run: |
Expand Down Expand Up @@ -91,7 +100,7 @@ jobs:
version="${GITHUB_REF_NAME#v}"
max_attempts=12
retry_delay=10
for p in @stim-cli/core stim-cli @stim-cli/expo-build-cache @stim-cli/metro; do
for p in @stim-cli/core @stim-cli/cache stim-cli @stim-cli/expo-build-cache @stim-cli/metro; do
for attempt in $(seq 1 "$max_attempts"); do
if v=$(npm view "$p@$version" version 2>/dev/null) && [ "$v" = "$version" ]; then
echo "$p -> $v"
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ stim guide settings
- [`@stim-cli/metro`](./packages/metro) shares Metro transforms and records logs.
- [`@stim-cli/expo-build-cache`](./packages/expo-build-cache) lets direct Expo
builds share native artifacts with Stim.
- [`@stim-cli/cache`](./packages/cache) holds the cache provider contract and
the local-first tier coordination behind both caches.
- [`@stim-cli/core`](./packages/core) contains shared internal cache contracts.

## Development
Expand Down
23 changes: 13 additions & 10 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ How to cut a new version of `stim-cli` to npm and GitHub. Keep this in sync with
what we actually do — when something changes, update both this file and the
real workflow at the same time.

## 0. The four packages
## 0. The five packages

```
packages/core @stim-cli/core shared primitives (cache roots, cache key, registration)
packages/stim-cli stim-cli the CLI
packages/cache @stim-cli/cache cache provider contract and tier coordination
packages/expo-build-cache @stim-cli/expo-build-cache Expo build cache provider
packages/metro @stim-cli/metro shared Metro transform cache + log reporter
```
Expand Down Expand Up @@ -47,7 +48,7 @@ fi
```

An npm `E404` means this is the first release for that package name. Confirm
all four names are available, use the intended first version, and review the
all five names are available, use the intended first version, and review the
full release diff. Complete the first-publication bootstrap in section 4,
step 7 before pushing the first tag.

Expand Down Expand Up @@ -83,24 +84,24 @@ Start from `main`, fully up to date with `origin/main`. Before candidate
preparation, `git status --short` may show only the draft
`docs/releases/X.Y.Z.md`.

1. **Bump the version in lockstep.** All four `package.json` files carry the
1. **Bump the version in lockstep.** All five `package.json` files carry the
same number, and `dist/cli.mjs` reads it from its own `package.json`:

```bash
pnpm -r --filter './packages/*' exec npm version X.Y.Z --no-git-tag-version
pnpm install --lockfile-only
```

The filtered `exec` bumps all four; `--no-git-tag-version` leaves the
The filtered `exec` bumps all five; `--no-git-tag-version` leaves the
candidate uncommitted and untagged. The lockfile duplicates every
workspace's version and must move with the manifests.

Confirm all four moved, and that dependency ranges between the packages
Confirm all five moved, and that dependency ranges between the packages
still name versions that will exist when publishing finishes:

```bash
grep -H '"version"' packages/*/package.json
grep -H '"@stim-cli/' packages/stim-cli/package.json packages/expo-build-cache/package.json packages/metro/package.json
grep -H '"@stim-cli/' packages/stim-cli/package.json packages/expo-build-cache/package.json packages/metro/package.json packages/cache/package.json
```

2. **Install and run the full pre-flight against those exact files:**
Expand Down Expand Up @@ -133,7 +134,7 @@ preparation, `git status --short` may show only the draft
packages). Every published JavaScript entry lives under `dist/`.

4. **Inspect the candidate diff.** `git status --short` should contain only the
four package manifests, `pnpm-lock.yaml`, and the draft
five package manifests, `pnpm-lock.yaml`, and the draft
release notes. Resolve anything else before QA.

## 3. Pre-tag QA gate
Expand Down Expand Up @@ -215,7 +216,7 @@ Before continuing:
```

One tag for the repo, not one per package: the packages share a version, so
a per-package tag would only say the same thing four times.
a per-package tag would only say the same thing five times.

6. **Publish the already-reviewed release notes in
`docs/releases/X.Y.Z.md`.** This committed file is the single source of
Expand Down Expand Up @@ -248,18 +249,19 @@ Before continuing:
provenance publish is REJECTED without it, E422), and a NEW package must
be published once by hand first -- npm's trusted-publisher settings live
on the package page, which does not exist until then. For the first
`stim-cli` release, create the `@stim-cli` npm organization, publish all four
`stim-cli` release, create the `@stim-cli` npm organization, publish all five
packages manually in dependency order, then configure each package's trusted
publisher for `appandflow/stim`, workflow `release.yml`, environment
`release`. Do this before pushing the first tag. The tagged workflow skips an
exact package version that already exists, uses the npm `next` dist-tag for
prereleases, then verifies all four registry versions. The same commands are
prereleases, then verifies all five registry versions. The same commands are
the manual fallback for later releases. Add `--tag next` to every command
when publishing a prerelease:

```bash
npm whoami # confirm login; if 401, `npm login` first
pnpm --filter @stim-cli/core publish --access public --otp <code>
pnpm --filter @stim-cli/cache publish --access public --otp <code>
pnpm --filter @stim-cli/metro publish --access public --otp <code>
pnpm --filter @stim-cli/expo-build-cache publish --access public --otp <code>
pnpm --filter stim-cli publish --access public --otp <code>
Expand All @@ -278,6 +280,7 @@ Before continuing:
version=X.Y.Z
cd /tmp && npx "stim-cli@$version" --version
npm view "stim-cli@$version" readme | head -c 200 # NOT "No README data found!"
npm view "@stim-cli/cache@$version" version
npm view "@stim-cli/expo-build-cache@$version" version
npm view "@stim-cli/metro@$version" version
```
Expand Down
2 changes: 1 addition & 1 deletion docs/e2e-and-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ default `~/.stim/workspaces/...`). Stim does not create a project
`.gitignore` entry for this state.

Stim has three test layers. The unit suite (`pnpm test`, Vitest, more than
2,000 cases across four packages) is the bulk of the coverage. On top of it sit
2,000 cases across five packages) is the bulk of the coverage. On top of it sit
two end-to-end layers that exercise the _published loop_ rather than individual
functions. The separately built runtime-floor job loads every published ESM
entry point on Node 20.19.4.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
],
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json",
"typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json",
"lint": "oxlint .",
"lint:fix": "oxlint --fix .",
"format": "oxfmt .",
Expand Down
21 changes: 21 additions & 0 deletions packages/cache/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Janic Duplessis

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
141 changes: 141 additions & 0 deletions packages/cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# @stim-cli/cache

The cache provider contract Stim uses for Metro transforms and native build
artifacts, plus the tier coordination, timeout, and warning policy that sit in
front of it.

The local filesystem is always the first tier. A project can add one optional
second tier by pointing `cache.provider` at a module that implements this
contract. Stim ships no network provider; writing one is an addition, not a
change to Stim.

If Stim is not installed globally, replace `stim` with `npx stim-cli`.

## Selecting a provider

```json
{
"cache": {
"provider": "./tools/cache-provider.cjs",
"options": { "bucket": "mobile-cache" }
}
}
```

The reference is a package name or a path relative to the settings file that
declares it. Machine settings override committed `.stim.json` settings, and the
existing nested merge rules apply to `cache.options`. Keep secrets out of
committed settings; read them from the environment or from machine settings.

## Writing a provider

```js
export const apiVersion = 1;

export async function createCacheProvider({ projectRoot, options }) {
return {
metro: {
async get({ key, cacheName, signal }) {
return null;
},
async set({ key, value, cacheName, signal }) {},
},
builds: {
async resolve({ platform, key, destinationDir, signal }) {
return null;
},
async store({ platform, key, sourcePath, overwrite, signal }) {},
},
};
}
```

A provider implements one or both capabilities. It owns transport,
serialization, archive format, authentication, and remote retention. Stim owns
fingerprints, cache keys, and local artifact paths.

`metro.get` returns the stored value or `null`.

`builds.resolve` returns an existing path to the artifact, or `null` for a
miss. `destinationDir` is a scratch directory Stim creates and owns: a provider
that fetches the artifact must materialize it there and return a path inside
it, and must leave the directory empty on a miss. The built-in filesystem tier
already holds the artifact, so it returns its own cache path instead.

`builds.store` receives the `.app` directory or `.apk` file that Stim just
built. `overwrite: false` must keep an entry that already exists for the key,
and `overwrite: true` must replace it.

Every call receives an `AbortSignal`. A provider must honor it: Stim abandons
the call at the deadline and keeps building or bundling with the local tier.

`stim gc` never deletes provider data, and the contract has no delete
operation, so shared team or CI data is never removed by a local command.

## Failure rules

Provider failures are cache misses. A timeout, module error, authentication
error, or network error produces one warning per failure class per command or
supervisor run and never fails a bundle, an install, a launch, or a successful
build.

## Contract tests

Run the shipped checks against your own module:

```js
import { runCacheProviderContract } from '@stim-cli/cache';

const results = await runCacheProviderContract({
provider: await createCacheProvider({ projectRoot, options }),
projectRoot,
workDir,
});

for (const result of results) {
if (!result.passed) throw new Error(`${result.name}: ${result.error}`);
}
```

Pass `providerModule` instead of `provider` to load the module the way Stim
does, which also checks `apiVersion` and the factory:

```js
const results = await runCacheProviderContract({
providerModule: './tools/cache-provider.cjs',
projectRoot,
workDir,
});
```

`cacheProviderContractChecks()` returns the same checks as individual cases for
a test runner that reports each one separately. Both helpers only check the
capabilities a provider advertises, bound every check with a deadline, and
verify that a call settles once its `AbortSignal` aborts.

## Budgets

Every provider call is bounded. The defaults are 2s for a Metro read, 10s for a
Metro write, 30s for a build lookup, 60s for a build upload, and 10s to load the
module. Override any of them per run with an environment variable:

```bash
STIM_CACHE_METRO_READ_TIMEOUT_MS=5000 stim start
STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS=60000 stim ios
```

The variables are `STIM_CACHE_METRO_READ_TIMEOUT_MS`,
`STIM_CACHE_METRO_WRITE_TIMEOUT_MS`, `STIM_CACHE_BUILD_RESOLVE_TIMEOUT_MS`,
`STIM_CACHE_BUILD_UPLOAD_TIMEOUT_MS`, and `STIM_CACHE_LOAD_TIMEOUT_MS`. Each
takes whole milliseconds; any other value keeps the default.

The tiered Metro store also exposes `flush()`, which resolves once queued
provider writes have drained. Metro never calls it; every in-flight write holds
a referenced deadline, so a Metro process drains on its own within the write
budget. It exists for tests and embedders that own the process.

Metro reads are also capped: at most six can be in flight, and the tier turns
itself off for the rest of the run after five consecutive failures, so a broken
provider costs one round of warnings rather than a timeout per transform.

The npm scope remains `@stim-cli` until the `@stim` scope is available.
Loading
Loading