Artifacts, cache and stash
Three mechanisms that all move files, for three different reasons.
| What it's for | Lifetime | Keyed by | |
|---|---|---|---|
| Artifact | Declaring a build output worth keeping | Retained by a server | path |
| Cache | Avoiding repeated work across runs | Permanent, content-addressed | your key |
| Stash | Handing files from one stage to another | Deleted when the run ends | name |
Ordering: only steps are ordered
Before anything else — where you write these calls in a stage body doesn't matter.
stage("Build", async () => {
await sh(["npm", "run", "build"]);
await cache.restore("node_modules", { key: "deps-v1" }); // still restored FIRST
});
Cache and stash operations happen at the stage boundary: restores before the
first step, saves after the last one succeeds. Only sh and file.write are ordered
steps. Writing a restore at the bottom of a body doesn't move it there.
Cache
import { cache, hashFiles } from "@flowwright/core";
stage("Install", async () => {
const key = `node-${await hashFiles("package-lock.json")}`;
await cache.restore("node_modules", { key });
await sh(["npm", "ci"]);
await cache.save("node_modules", { key });
});
Restores before the stage runs, saves after it succeeds. Failures on either side never fail the run — a cache is an optimization, not a dependency.
plugin-cache wraps this pair for you, and
Running in CI covers persisting the cache between CI jobs.
The run output shows what happened:
↺ cache hit: node_modules
⤓ cache saved: node_modules
cache.restore tells you nothing
// This does not work.
const { hit } = await cache.restore("node_modules", { key });
if (!hit) await sh(["npm", "ci"]);
cache.restore returns nothing, because it runs during the recording pass — before
any cache is consulted, possibly on a different machine than the one that will do the
restoring. There is no hit to report yet.
Write the command so it's cheap either way:
await cache.restore("node_modules", { key });
await sh(["npm", "ci"]); // fast when node_modules is already warm
A restore deletes the destination
cache.restore("dist", …) removes whatever is at dist before unpacking. Don't
restore over a directory containing anything you still need — and don't point a
restore path outside the workspace; nothing stops you, and it will delete what's
there.
Keys are write-once
Saving to a key that already exists is a no-op, forever. A cache entry is never updated in place. To refresh, change the key — which is why keys usually contain a content hash:
const key = `node-${await hashFiles("package-lock.json")}`;
A hand-written node-v1 will keep serving the day-one contents until you bump it to
node-v2.
hashFiles
Hashes file contents into a short, stable string for use in a key.
await hashFiles("package-lock.json");
await hashFiles(["**/*.go", "go.sum"]);
Returns 16 hex characters. Runs at plan time, so the key is a concrete literal in the plan.
| Pattern | Meaning |
|---|---|
* | anything except / |
** | anything, crossing directories |
? | one character except / |
No brace expansion — {a,b} is literal, not alternation.
Directories always skipped: node_modules, .git, .flowwright, dist,
coverage, .next, .turbo. A pattern like **/*.js will never match anything
under dist/.
hashFiles("pnpm-lock.yaml") in a project that uses npm doesn't error — it returns
the stable hash of an empty set. So does every other typo'd pattern, which means they
all produce the same key, and unrelated stages start sharing one cache entry.
Check it once with flow explain: the key is right there in the recorded command.
Stash
For handing files between stages within a single run:
import { stash, unstash } from "@flowwright/core";
stage("Build", async () => {
await sh(["npm", "run", "build"]);
await stash("dist", { includes: ["dist/**"] });
});
stage("Deploy", {
needs: ["build"],
run: async () => {
await unstash("dist");
await sh(["./deploy.sh"]);
},
});
Saved after the stage succeeds, restored before the dependent stage's steps.
includes defaults to everything; excludes is subtracted.
Only regular files are copied — empty directories and symlinks are dropped. A restore merges into the workspace rather than replacing it.
Stashes never survive the run. The directory is deleted when the run ends, however it ends. For anything that needs to outlive the run, use a cache or an artifact.
unstash is silentunstash("dsit") restores nothing, doesn't fail the stage, and prints nothing at
all — the CLI doesn't render stash events. The stage carries on with missing files
and fails later somewhere confusing. Copy the name from the stash call.
Artifacts
import { artifact } from "@flowwright/core";
stage("Build", {
produces: ["dist"],
run: async () => {
await sh(["npm", "run", "build"]);
await artifact.upload("coverage/lcov.info");
},
});
produces and artifact.upload are the same mechanism — a declaration that these
paths are outputs. Both end up in one de-duplicated list, visible in flow explain:
build ← install
$ npm run build
→ produces: dist, coverage/lcov.info
flow run on your machine records the declaration and does nothing else. Nothing is
copied anywhere — the files are already in your working tree, where the build left
them.
Collection is a server-side feature: when a run executes under the FlowWright server,
declared artifacts are gathered and retained after the run. Declaring produces
locally is still worth it — it documents the stage's outputs and makes the pipeline
portable to a server without changes.