~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
276 lines · 12.3 KB Raw History
# 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/<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:

```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 `<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:

```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/<name>`, 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
<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

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 `<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](../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_<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

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 <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

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/<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](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.