Shared steps
A knotless workflow is one container and a list of shell steps. Most of those steps are the same from one repository to the next: install a toolchain at a pinned version, install packages only when the lockfile changes, wait for a commit’s checks to pass before deploying. Without a way to share them, each is pasted into every workflow that needs it, and the copies drift: a fix made to one reaches none of the others.
A shared step is one of those blocks, written once in a repository and used by name from any workflow. This page explains how a workflow uses one, why the version it runs cannot change under it, and what a shared step can see. The keys themselves are listed in the workflow reference.
A step file and a workflow that uses it
Section titled “A step file and a workflow that uses it”A shared step is a YAML file at .knotless/steps/<step>.yml in some
repository. It names its inputs, the commands it needs in the image, the
secrets it may be given, and its own steps, each a shell script:
# .knotless/steps/wait.yml in alice.example.com/cidescription: Wait for this commit's checks to passinputs: workflows: {description: The workflows to wait for, required: true} minutes: {default: "25"}needs: [curl]secrets: [TOKEN]steps: - name: wait run: | for wf in $INPUT_WORKFLOWS; do echo "waiting $INPUT_MINUTES minutes for $wf" # poll this commit's checks with curl and $TOKEN done timeout: 30A workflow step then has uses in place of run. A step has one or the
other, never both and never neither, and with and secrets are taken
only beside uses:
secrets: [TOKEN]steps: - uses: alice.example.com/ci/wait@1 with: {workflows: test check} secrets: [TOKEN] - name: deploy run: ./deploy.shuses reads <owner>/<repo>/<step>@<version>: the owner is a handle or
a DID (an account’s permanent ID), and the step is the file’s name. The
version alone does not say which code runs. That is the lock’s job:
# .knotless/uses.lock in the using repositoryalice.example.com/ci/wait@1: version: 1.2.0 commit: 3f1c9a0e5b7d2c4f6a8e0b1d3c5e7f9a1b2c4d6e release: at://did:plc:…/dev.knotless.release/…Details:
- A step name is 1 to 64 letters, digits,
-and_. - A step with
usesand nonameis named after the step file:wait. - A step file refuses keys it does not know. Its steps are scripts, or steps that use other shared steps, at most four deep.
- The lock is a map from each
usesstring, exactly as written, to the version it pins, the commit (40 hex digits) and, optionally, the release’s AT-URI.
Why a shared step cannot change under you
Section titled “Why a shared step cannot change under you”The danger with code fetched by name is that the name can be moved. On
GitHub, uses: owner/repo@v1 is resolved when the job runs, and a tag
can be pointed at any commit. In March 2025 most tags of
tj-actions/changed-files were moved to a malicious commit, which ran in
more than 23,000 repositories and printed their secrets to public logs
(CVE-2025-30066). GitHub’s advice since is to pin each action to a commit
hash by hand; a lock file and immutable releases are on its roadmap.
knotless starts with the lock. The @1 in a workflow says which versions
are acceptable. The lock says which one was chosen and, in commit,
exactly which tree it is. The runner runs that commit and nothing else.
Moving a tag, deleting a release or pushing new commits to the step’s
repository changes nothing a lock already pins. Rewriting the step’s
history so the commit is gone makes a run fail, never run something
else. A tag-moving attack has
to change the using repository’s own uses.lock, in a diff a reviewer
reads.
A version spec takes the versions under it:
| Spec | Takes |
|---|---|
@1 | 1, and any 1.x… (not 10.0.0) |
@1.2 | 1.2, and any 1.2.x |
@1.2.0 | only 1.2.0 |
The spec still guards the lock: a lock that pins a version the spec does not take fails the run.
Where a lock comes from
Section titled “Where a lock comes from”A step is released as a package of its own, @step/<name>, whose one
file is the step file, published with the commit it was made from:
knotless publish knotless://alice.example.com/ci @step/wait 1.2.0 \ .knotless/steps/wait.yml --commit 3f1c9a0e5b7d…Since a release’s key includes a hash of its package’s name, one repository can hold many steps, each with versions of its own. A release is created once and never updated (packages).
knotless uses update, in a checkout, reads the workflows’ uses and
pins each to the highest release its spec takes, leaving out
pre-releases unless the spec names one. It reads the step file at the
release’s commit and checks it against the file the release holds, by
hash, so a release cannot name one commit and carry another file. It
pins the shared steps those use too, and writes the lock, with each
release’s URI in release for people reading it. The runner does not
read release: the commit is what runs. Named one by one, uses update <uses> moves only those, leaving the rest as pinned. Either way the
change to the lock is a diff in a pull.
Where the runner reads a step from
Section titled “Where the runner reads a step from”When a run starts, before its container does, the runner reads
uses.lock at the commit being run and replaces each uses step with
the step file’s steps.
It reads the step file from its own mirror of the step’s repository: it syncs the mirror’s refs, then has git read the file at the locked commit. It does not fetch the file from the author’s PDS (the server that holds their account’s records) on each run. That would make every user’s CI depend on the author’s PDS being up, and PDSes do stall. The mirror is shared between runs, and a commit, once mirrored, never changes.
If anything is missing, the check ends as error before any step runs,
saying which:
- the workflow uses shared steps and there is no
.knotless/uses.lock - the lock does not pin a
usesstring - the lock pins a version the spec does not take
- the step file is not at the pinned commit of its repository
How it runs
Section titled “How it runs”A shared step runs in place, in the workflow’s own container, between the
steps around it. It sees what earlier steps installed, and later steps
see what it leaves. Its steps are named <step name> / <their name>, so
the check above reads wait / wait.
Its steps run in the using repository’s checkout, /work. The step’s
own repository is checked out beside it too, at the locked commit,
read-only, and its steps find it as KNOTLESS_STEP_DIR (/steps/<n>
under docker). A step file can so keep its scripts and other files in
its repository, as any code is kept, rather than inline.
A step file can itself use shared steps, as a workflow does. They are
pinned in the same lock, which is flat, one entry per uses string, and
put in place in turn, named <step> / <theirs> / <their step>. A step
that uses itself, through any chain, fails the run, and so does a chain
more than four deep. A nested step is given only those of its secrets
that the step using it was given. Its secrets maps from the names of
the step file using it, as a workflow’s maps from the workflow’s.
A step file’s needs lists commands it expects in the image. When it
names any, a first step, wait / needs, checks each one with command -v and fails with “wait needs curl in the image”. The workflow chooses
the image; the step says plainly what it requires of it.
A uses step can name on, the refs it runs at, as any step can
(Steps for some refs).
It holds for every step the shared step comes to, and a step in the file
may have an on of its own, so each runs only where both match. A step
that should run only on the default branch, as a publish or a deploy,
needs no input of its own for that, and no shell test of
KNOTLESS_RUN_REF that a wrapper could not reach. Pull and batch runs
are at no ref yet, so a step with on runs in neither.
Inputs reach each of its steps as environment variables, INPUT_<NAME>,
upper-cased with - made _. The value is the one with gives, else
the input’s default, else empty. A required input with neither fails
the run, and so does a with key that is not an input, so a typo shows.
What a shared step can see
Section titled “What a shared step can see”A shared step is someone else’s code in your container, so the question that matters is what secrets it gets. The answer is only those the workflow hands it.
A uses step lists the secrets it passes. Each must be one the workflow’s
secrets names, or the workflow does not parse, and one the step file’s
secrets asks for, or the run fails. The expanded steps see those and no
others. A plain run step sees every secret granted to the run, as it
always has. Pull and batch runs still get no secrets at all.
The step file chooses the names it asks for, and the workflow already
has its own, which need not match: a workflow may keep its deploy key as
DEPLOY_SSH_KEY while a step asks for SSH_KEY. So secrets can be a
map instead, from the name the step file asks for to the workflow’s:
secrets: [DEPLOY_SSH_KEY]steps: - uses: alice.example.com/steps/ssh-key@1 secrets: {SSH_KEY: DEPLOY_SSH_KEY}The expanded steps see the value as SSH_KEY, and nothing as
DEPLOY_SSH_KEY. A list is the same map with each name on both sides.
The renaming is only in what the step sees: the runner grants, and the
log names a secret it does not grant, by the workflow’s name, as the
grant is the repository’s and the step file’s names are someone else’s.
Keeping secrets apart took a change to how every step starts. Secrets
used to go into the container’s environment when it started, where any
step could read all of them from /proc/1/environ. Now each step’s
docker exec is given -e NAME for each secret that step may see, with
the values passed through the docker client’s environment, never its
arguments. Under the shell executor, each step’s process gets its own.
A process a step leaves running still holds the secrets it was given. In
a workflow that uses shared steps, then, what a step given secrets
leaves running is ended as it ends, under docker, and the log says so
(==> ended <n> of what <step> left running, as it was given secrets).
A workflow with no shared steps keeps what its steps start, as before,
so one step can still start a server for the next.
Files are another matter. The container is shared, so a shared step can
read what earlier steps wrote, such as a key in ~/.ssh. Keeping that
apart would mean a container per step, and losing what makes a step
useful to the next: the tools it installed. Treat a shared step as code
you run, and pin only authors you would let run code in that workflow.
Whose steps a pull may bring
Section titled “Whose steps a pull may bring”A pull can change the lock, and so bring in steps from anyone. A pull whose lock names an account the base’s lock does not, which the repository does not vouch for (its owner and collaborators), runs as an agent’s pull does (Whose code runs): on caches of its own for a workflow asking for secrets, with nothing it writes kept for others, and no pass a push may reuse. Its log says so, naming the accounts. A merge-queue batch holding one runs the same way. Once it lands, the default branch’s runs use the steps with its secrets, and the reviewed diff of the lock is the approval. For the rest of who runs whose code, see security.
Finding a step
Section titled “Finding a step”The pages list every shared step the appview has indexed at /steps,
by name, each with its repository, what it does, its latest version, how
many public repositories pin it and how many secrets it asks for. A
step’s own page, /steps/<owner>/<repo>/<step>, gives the lines to paste
into a workflow, its inputs, the secrets and commands it asks for, each
version with the commit it was published at, and the repositories that
use it. A repository that publishes steps links to them from its release
line, and each step’s release links to its page. How the appview finds
them, and counts their uses from each repository’s lock, is in
serve.
Not built yet
Section titled “Not built yet”- Outputs. Steps pass state to later steps through files in the shared container. This was left out of the first version on purpose.
- Themes and other kinds of shared thing in the directory of steps.
- Private step repositories. A step’s repository is read through the runner’s mirror as a public one is, and shared steps have been tested only with public ones.