Skip to main content

Artifacts, cache and stash

Three mechanisms that all move files, for three different reasons.

What it's forLifetimeKeyed by
ArtifactDeclaring a build output worth keepingRetained by a serverpath
CacheAvoiding repeated work across runsPermanent, content-addressedyour key
StashHanding files from one stage to anotherDeleted when the run endsname

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.

PatternMeaning
*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/.

A pattern that matches nothing still returns a hash

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.

A typo'd unstash is silent

unstash("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
Local runs don't collect artifacts

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.