# 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](../reference/workflows.md#shared-steps). ## A step file and a workflow that uses it A shared step is a YAML file at `.knotless/steps/.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: ```yaml # .knotless/steps/wait.yml in alice.example.com/ci description: Wait for this commit's checks to pass inputs: 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: 30 ``` A 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`: ```yaml secrets: [TOKEN] steps: - uses: alice.example.com/ci/wait@1 with: {workflows: test check} secrets: [TOKEN] - name: deploy run: ./deploy.sh ``` `uses` reads `//@`: 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: ```yaml # .knotless/uses.lock in the using repository alice.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 `uses` and no `name` is 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 `uses` string, 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 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 A step is released as a package of its own, `@step/`, whose one file is the step file, published with the commit it was made from: ```sh 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](packages.md)). `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 ` 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 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 `uses` string - 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 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 ` / `, 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/` 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 ` / / `. 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](../reference/workflows.md#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_`, 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 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: ```yaml 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 of what 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 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](runner.md#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](security.md). ## 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///`, 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](serve.md#shared-steps). ## 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.