Run a bash script exactly once across concurrent GitHub Actions jobs that share a key — distributed run-once / leader election, built on the Actions cache service.
One job wins an atomic reservation, runs your script, and caches its stdout. Every other job with the same key downloads that same output instead of re-running. A non-zero exit on the winner is propagated to every racer.
The cache service's CreateCacheEntry is an atomic reservation: given N
concurrent callers on the same key+version, exactly one gets a write URL and the
rest get already_exists. The entry only becomes visible on
FinalizeCacheEntryUpload. That's a leader-election lock with a published
result — this action wraps it.
| input | required | default | description |
|---|---|---|---|
key |
yes | All jobs sharing this key elect one winner. Make it unique to the work (e.g. include a run id, or a content hash to memoize across runs). | |
run |
yes | Bash script run only on the winner. Its stdout becomes output. |
|
timeout-seconds |
no | 600 |
How long a losing job waits for the winner to finalize before failing. |
| output | description |
|---|---|
output |
Stdout of the winning job's script — identical for every racer. |
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- id: once
uses: cnuss/actions-run-once@v1
with:
key: expensive-step-${{ github.run_id }}
run: |
echo "computing once for all shards..."
date -u +%s
- run: echo "every shard sees: ${{ steps.once.outputs.output }}"All four shards call the action; one runs the script, the other three reuse its output. Useful for: one-time setup across a matrix, single migration/seed, generating a shared build id, or memoizing an expensive computation by content hash.
CreateCacheEntry { key, version }— atomic race. Winner gets a signed upload URL; losers getalready_exists.- Winner: runs
bash -c "$run", PUTs a JSON envelope{exit, output}to the blob, thenFinalizeCacheEntryUpload(which publishes the entry). - Losers: poll
GetCacheEntryDownloadURLuntil finalized, download the envelope, setoutput, and mirror the winner's exit code.
version is sha256("run-once-v1:" + key), so distinct keys never collide.
- Dependency-free: no
node_modules, no bundling, no build step. Node 24. - Needs
permissions: actions: writein the calling job (cache write). - The reservation is immutable: once a key finalizes, later runs reuse it until the cache entry is evicted or deleted. Use a per-run key if you want fresh execution each run.
- Convergence latency. The reservation race (
CreateCacheEntry) is atomic and instant — exactly one winner, always. But a finalized entry becomes visible to the losers only as fast as the cache service's read replicas propagate it, which is eventually-consistent: usually sub-second to a few seconds, occasionally tens of seconds, rarely a few minutes. Losers fan a small swarm of concurrent probes across replicas to converge on the fastest one, and wait up totimeout-seconds. Treat this as "run an expensive thing once; the other jobs can afford to wait," not as a low-latency mutex. - Set
ACTIONS_STEP_DEBUG=true(or re-run with debug logging) to log every request's method, redacted URL, status, headers, and body.