ref
Reference any producer's output by a rename-safe handle. Actio infers the producer and wires needs and outputs for you.
Threading a value between jobs in raw YAML is the most error-prone hand-work in a
workflow: give the producing step an id, declare jobs.<id>.outputs, add the
consumer to needs, then read it back through ${{ needs.<job>.outputs.<name> }}.
Every piece is stringly-typed and silently breaks on a rename. ref collapses all of
it. Reference a producer's output anywhere as ${{ ref.<handle>.<output> }} and Actio
finds the producing step, assigns its id, synthesizes job.outputs, and adds the
needs edges for you. No ref: key is required: the reference itself is the contract.
Where share wires outputs the compiler mints itself, ref
generalizes that same engine to outputs the compiler did not mint: action uses:
outputs, hand-written $GITHUB_OUTPUT writes, and reusable call-job outputs. share
and ref are two front-ends over one reference graph.
name: Build
on: [push]
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
name: node
build:
runs-on: ubuntu-latest
steps:
- run: echo "building on ${{ ref.node.node-version }}"name: Build
on:
- push
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
name: node
id: step_node
outputs:
node-version: ${{ steps.step_node.outputs.node-version }}
build:
runs-on: ubuntu-latest
steps:
- run: echo "building on ${{ needs.setup.outputs.node-version }}"
needs:
- setupname: Build
on: [push]
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
name: node
build:
runs-on: ubuntu-latest
steps:
- run: echo "building on ${{ ref.node.node-version }}"name: Build
on:
- push
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
name: node
id: step_node
outputs:
node-version: ${{ steps.step_node.outputs.node-version }}
build:
runs-on: ubuntu-latest
steps:
- run: echo "building on ${{ needs.setup.outputs.node-version }}"
needs:
- setupThe setup step carries no ref: at all. The ${{ ref.node.node-version }} reference
in build is what turns it into a producer: node matches the step name, so Actio
gives the step an id, publishes node-version once on jobs.setup.outputs, adds
needs: [setup], and rewrites the reference to ${{ needs.setup.outputs.node-version }}.
Rename the step and every reference moves with it; there is no string to keep in sync.
Omitting ref: is byte-identical to writing ref: [node-version] on the same step.
Inference is the default
A step becomes a producer the moment a consumer references it. For each
${{ ref.<handle>.<output> }}, Actio looks for the producing step by handle:
- A step whose
idequals the handle (preferred, and must be unique in its job). - Otherwise the first step whose
nameis a valid token (^[A-Za-z_][A-Za-z0-9_-]*$) and slugifies to the handle.
Only handles that are actually referenced are resolved, so unrelated named steps never collide. The output set accrues from the references themselves; the compiler emits the exact set you read. This covers both producer kinds:
- Action
uses:steps are unverifiable by design (Actio does not readaction.ymlmetadata), so a referenced output is always accepted and wired. - Hand-written
run:steps are statically scanned for their$GITHUB_OUTPUTwrites. When that scan is authoritative, referencing an output the script never writes is a hardref-output-unwrittenerror. When it is not, Actio degrades to a singleref-output-unscannablewarning and wires the reference anyway (see Run-step scanning).
Run-step scanning
For a run: producer Actio parses the script for the output names it writes to
$GITHUB_OUTPUT, recognizing the common spellings:
- name: tag
run: |
echo "version=1.2.3" >> "$GITHUB_OUTPUT" # simple assignment
echo "notes<<EOF" >> "$GITHUB_OUTPUT" # multiline opener
echo "shipped" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
printf "sha=%s\n" "$GITHUB_SHA" >> "$GITHUB_OUTPUT"The scan is deliberately conservative. It is authoritative only when every write is
fully parseable and at least one name was resolved; then a reference to an unwritten
output is a hard ref-output-unwritten error. If any write is dynamic or
un-dissectable (echo "$NAME=...", a piped or multi-redirect line, an unterminated
heredoc), the scan degrades: Actio emits one ref-output-unscannable warning per
producer and wires every referenced output anyway. The scanner never claims an output is
absent from a script it could not fully parse, so it can never raise a false hard error.
Explicit ref: (escape hatch)
Inference is the primary path. Reach for an explicit ref: only when you need to pin a
contract the reference graph cannot derive:
- Rename the handle so it does not track the step
name/id. - Declare an explicit output contract on a
run:step, e.g. to fail the build the moment a reference drifts from a hand-maintained list rather than relying on the scan.
ref takes a positional array or a { handle?, outputs } map:
# pin a handle that does not match the step name
- uses: actions/setup-node@v4
ref: { handle: node, outputs: [node-version] }
# declare an explicit run output contract
- id: tagger
run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"
ref: [version]Prop
Type
ref: { handle: node, outputs: [node-version] } is byte-identical to
ref: [node-version] on a step named node; the explicit handle: simply wins over the
derived one. With an explicit ref:, an action reference to an undeclared output warns
(ref-output-unknown-on-action) and a run reference to an undeclared output is a hard
ref-unknown-output error: the declared list is the contract, and the run script is
not scanned.
Handles
A producer's handle is how consumers address it. It is resolved explicit-first:
- An explicit
handle:on therefmap form. - A slug of the step
name, when no handle is given. - The synthesized step
id, as a last resort.
Two producers that resolve to the same handle in the same job is a ref-ambiguous
compile error, never a silent pick. When a handle is reused across different jobs,
qualify the reference with the job id: ${{ ref.<job>.<handle>.<output> }}.
Same job vs cross job
The wiring follows the reference site:
- Same job resolves to
${{ steps.<id>.outputs.<name> }}. Noneedsand nojob.outputsare added. - Cross job resolves to
${{ needs.<job>.outputs.<name> }}, synthesizes the producer'sjob.outputsonce (deduped across however many consumers read it), and merges aneedsedge onto every consuming job.
Reusable call-jobs
Reference a reusable call-job's output as ${{ ref.job.<call_id>.<output> }}. The
call-job already declares its own outputs, so Actio adds only the needs edge.
Diagnostics
ref fails the build with a precise diagnostic instead of emitting broken YAML:
ref-unknown-step: the handle matches no step.ref-output-unwritten: an inferredrunproducer with an authoritative scan never writes the referenced output.ref-output-unscannable(warning): an inferredrunproducer's$GITHUB_OUTPUTwrites cannot be statically verified; the reference is wired anyway.ref-unknown-output: an explicitrunproducer is missing the referenced output.ref-output-unknown-on-action(warning): an explicit action producer is referenced for an output it did not declare.ref-ambiguous: a handle collides, on declaration or at a bare reference.ref-cycle: the addedneedsedges would form a cycle.ref-self: a step references its own handle.ref-output-undeclared: an explicitrefproducer declares nooutputs:.ref-matrix-clobber: a cross-job reference targets a matrix-fanned producer, whose per-leg outputs would clobber each other.
Gotchas
needs is merged, not replaced
Every job that reads a cross-job ${{ ref.<handle>.<output> }} gains a needs: edge
on the producing job, merged with any needs: you already declared. A reference
therefore also defines ordering: a consumer always runs after its producer.
Matrix producers cannot be referenced cross-job
A matrix expands one job into many legs that all write the same output name. A
cross-job reference cannot pick a leg, so it is a ref-matrix-clobber compile error.
Reference such outputs within the same job, where each leg keeps its own
steps.<id>.outputs.<name>.
See the ref entry in the syntax reference for the full option
list.