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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A workflow step can run only at some refs

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:run/step-on
.changeset/step-on.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# A workflow step can run only at some refs
A step with `on: {refs: [refs/heads/main, refs/tags/*]}` runs only at a push to, or a run asked for at, a ref those patterns match, as `on.push.refs` are matched; anywhere else it passes unrun, listed `skipped`, its log saying why. A pull's run and a merge-queue batch's run are at no ref, so such a step runs in neither. On a step with `uses`, its `on` holds for every step the shared step comes to, beside their own.
crates/knotless/src/uses.rs+33−4
@@ -22,7 +22,7 @@ use crate::git::Git;
use crate::mirror;
use crate::read;
use crate::url::RepoUrl;
use crate::workflow::{Step, Workflow, valid_name};
use crate::workflow::{Step, StepOn, Workflow, valid_name};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -212,13 +212,17 @@ pub fn expand(step: &Step, used: &Use, file: &StepFile, from: usize) -> Result<V
if let Some(name) = passed.iter().find(|n| !file.secrets.contains(n)) {
bail!("{uses} does not ask for the secret {name}");
}
let ours = |name: &str, run: String, timeout| Step {
// Its `on`, and those of the shared steps it came from, hold for each.
let within: Vec<StepOn> = step.within.iter().chain(&step.on).cloned().collect();
let ours = |name: &str, run: String, timeout, on| Step {
name: format!("{} / {name}", step.name),
run,
env: env.clone(),
secrets: Some(passed.clone()),
timeout,
from: Some(from),
on,
within: within.clone(),
..Step::default()
};
let mut steps = Vec::new();
@@ -228,11 +232,12 @@ pub fn expand(step: &Step, used: &Use, file: &StepFile, from: usize) -> Result<V
file.needs.join(" "),
used.step
);
steps.push(ours("needs", script, None));
steps.push(ours("needs", script, None, None));
}
for theirs in &file.steps {
if theirs.uses.is_none() {
steps.push(ours(&theirs.name, theirs.run.clone(), theirs.timeout));
let (run, on) = (theirs.run.clone(), theirs.on.clone());
steps.push(ours(&theirs.name, run, theirs.timeout, on));
continue;
}
let given = theirs.secrets.iter().flatten();
@@ -240,6 +245,7 @@ pub fn expand(step: &Step, used: &Use, file: &StepFile, from: usize) -> Result<V
steps.push(Step {
name: format!("{} / {}", step.name, theirs.name),
secrets: Some(given.collect()),
within: within.clone(),
..theirs.clone()
});
}
@@ -587,6 +593,8 @@ mod tests {
);
let bad = parse_step("x.yml", b"needs: ['curl; rm -rf /']\nsteps: []\n").unwrap_err();
assert!(bad.to_string().contains("not a command's name"), "{bad}");
let gated = parse_step("x.yml", b"steps:\n - {run: x, on: {refs: []}}\n").unwrap_err();
assert!(gated.to_string().contains("naming no refs"), "{gated}");
let odd = parse_step("x.yml", b"steps: 3\n").unwrap_err();
assert!(odd.to_string().starts_with("x.yml: "), "{odd}");
}
@@ -654,6 +662,27 @@ mod tests {
assert_eq!(theirs[0].secrets, Some(vec!["TOKEN".to_string()]));
assert_eq!(theirs[0].with["x"], "y");
// A shared step's `on` holds for every step it comes to, beside
// their own, and for those of the shared steps they use.
let gated = parse_step(
"g.yml",
b"needs: [curl]\nsteps:\n - run: a\n - {run: b, on: {refs: [refs/tags/*]}}\n - uses: b.test/s/inner@1\n",
)
.unwrap();
let main = StepOn {
refs: vec!["refs/heads/main".into()],
};
let mut outer = step(&[], None);
outer.on = Some(main.clone());
let ours = expand(&outer, &used(), &gated, 0).unwrap();
assert!(ours.iter().all(|s| s.within == [main.clone()]));
assert_eq!(ours[2].on.as_ref().unwrap().refs, ["refs/tags/*"]);
assert_eq!((ours[1].on.as_ref(), ours[3].on.as_ref()), (None, None));
let mut inner = ours[3].clone();
inner.on = ours[2].on.clone();
let deeper = expand(&inner, &used(), &gated, 0).unwrap();
assert_eq!(deeper[0].within, [main, ours[2].on.clone().unwrap()]);
let wrong = |s: Step| expand(&s, &used(), &file, 0).unwrap_err().to_string();
assert!(wrong(step(&[], None)).contains("needs the input workflows"));
let unknown = wrong(step(&[("workflows", "t"), ("nope", "x")], None));
crates/knotless/src/workflow.rs+64−0
@@ -23,6 +23,13 @@
//! passes, at a branch neither the default nor protected, the runner
//! commits what it changed among them onto that branch and pushes it, as
//! screenshots taken again in the image their baselines come from.
//!
//! A step may name `on`, the refs it runs at, matched as a push trigger's
//! are, against the ref a push moved or a run was asked for at. A pull's
//! run and a merge queue batch's are at no ref of the repository's, as
//! nothing has moved one yet, so such a step runs in neither. One left
//! out is not run and counts as passed. On a shared step, its `on` holds
//! for every step it comes to, beside any of their own.
use crate::git::Git;
use crate::read;
@@ -206,6 +213,21 @@ pub struct Step {
/// it, or past it only in a run its owner asked for.
#[serde(default)]
pub timeout: Option<u64>,
/// The refs it runs at, if not every one its workflow runs at.
#[serde(default)]
pub on: Option<StepOn>,
/// The `on`s of the shared steps it was put in place of, each of which
/// must match too; never in a workflow file.
#[serde(skip)]
pub within: Vec<StepOn>,
}
/// Where a step runs: at the refs it names.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct StepOn {
/// Full ref names, matched as [`Push::refs`] are.
pub refs: Vec<String>,
}
impl Step {
@@ -225,9 +247,21 @@ impl Step {
(false, false) if !self.with.is_empty() || self.secrets.is_some() => {
Some("has `with` or `secrets`, which only a step with `uses` takes")
}
_ if self.on.as_ref().is_some_and(|on| on.refs.is_empty()) => {
Some("has an `on` naming no refs")
}
_ => None,
}
}
/// The refs of its first `on`, its shared steps' before its own, that
/// `at` matches none of: why it does not run there. `None` when it
/// runs. A run at no ref, as a pull's, matches none.
pub fn unmatched(&self, at: Option<&str>) -> Option<&[String]> {
let missed = |on: &&StepOn| !at.is_some_and(|at| on.refs.iter().any(|p| matches(p, at)));
let mut all = self.within.iter().chain(&self.on);
all.find(missed).map(|on| on.refs.as_slice())
}
}
impl Workflow {
@@ -493,6 +527,36 @@ mod tests {
assert!(shape.contains("d.yml: step 1: `uses: a/b@1`"), "{shape}");
}
#[test]
fn a_step_runs_on_some_refs() {
let text = "steps:\n - run: x\n - run: y\n on: {refs: [refs/heads/main, refs/tags/*]}\n - uses: a.test/steps/publish@1\n on: {refs: [refs/heads/main]}\n";
let w = parse("d.yml", text.as_bytes()).unwrap();
let main = Some("refs/heads/main");
assert_eq!(w.steps[0].unmatched(main), None);
assert_eq!(w.steps[0].unmatched(None), None, "no `on`, every run");
assert_eq!(w.steps[1].unmatched(main), None);
assert_eq!(w.steps[1].unmatched(Some("refs/tags/v1")), None);
let refs = w.steps[1].on.as_ref().unwrap().refs.as_slice();
assert_eq!(w.steps[1].unmatched(Some("refs/heads/x")), Some(refs));
assert_eq!(w.steps[1].unmatched(None), Some(refs), "a pull's run");
assert_eq!(w.steps[2].unmatched(main), None);
// A shared step's `on` and the step's own must both match.
let mut inner = w.steps[1].clone();
inner.within = vec![w.steps[2].on.clone().unwrap()];
assert_eq!(inner.unmatched(main), None);
let tag = inner.unmatched(Some("refs/tags/v1")).unwrap();
assert_eq!(tag, ["refs/heads/main"]);
let wrong = |text: &str| parse("d.yml", text.as_bytes()).unwrap_err().to_string();
let empty = wrong("steps:\n - {run: x, on: {refs: []}}\n");
assert!(
empty.contains("step 1 has an `on` naming no refs"),
"{empty}"
);
let paths = wrong("steps:\n - {run: x, on: {refs: [a], paths: [b]}}\n");
assert!(paths.contains("unknown field `paths`"), "{paths}");
assert!(wrong("steps:\n - {run: x, on: {}}\n").contains("missing field `refs`"));
}
#[test]
fn superseded() {
let w = parse("d.yml", b"superseded: finish\nsteps: []\n").unwrap();
crates/knotless-run/src/lib.rs+40−2
@@ -2136,7 +2136,7 @@ impl Runner {
self.reporter.put(&watch.remote, rkey, &check).await?;
let (ended, took) = (Some(check.status.as_str()), Some(began.elapsed()));
telemetry::run("run ended", watch, subject, &wf.name, ended, took);
if check.status == status::SUCCESS {
if check.status == status::SUCCESS && !gated(&check) {
self.keep_passed(watch, subject, wf, rkey)?;
}
if check.status == status::CANCELLED && *self.stopping.borrow() {
@@ -2820,6 +2820,7 @@ impl Runner {
progress: &progress,
granted: &granted,
step_dirs: &step_dirs,
at: ref_at(subject),
};
let ran = (self.run_steps(&session, wf, check, steps, cancelled, asked)).await;
session.stop().await;
@@ -2852,7 +2853,7 @@ impl Runner {
} else if let Some(at) = warms(watch.pulls_only, subject, wf).filter(|_| passed) {
let (kept, moved) = lease.advance(self.opts.cache_max, at).await?;
warmed = warmed_lines(&kept, &moved);
} else if passed && keeps(subject, wf) && !watch.pulls_only {
} else if passed && keeps(subject, wf) && !gated(check) && !watch.pulls_only {
let rkey = run_rkey(watch, subject, &wf.name);
lease.keep(&rkey, self.opts.cache_max).await?;
}
@@ -3033,6 +3034,7 @@ impl Runner {
progress,
granted,
step_dirs,
at,
} = steps;
let mut failed = false;
let begun = std::time::Instant::now();
@@ -3046,6 +3048,17 @@ impl Runner {
continue;
}
writeln!(log, "==> {}", step.name)?;
// One whose `on` the run's ref does not match passes unrun.
if let Some(refs) = step.unmatched(at.ok()) {
let why = at.map_or_else(str::to_string, |r| {
format!("{r} is not {}", refs.join(" or "))
});
writeln!(log, "==> skipped, as {why}")?;
check.steps.push(skipped_step(step));
let steps = check.steps.clone();
progress.send_replace(live::Progress { steps, going: None });
continue;
}
progress.send_modify(|p| p.going = Some(step.name.clone()));
telemetry::step(&check.commit, &wf.name, &step.name, None, None, None);
let own = step.env.iter().map(|(k, v)| (k.clone(), v.clone()));
@@ -3348,6 +3361,23 @@ async fn changes(git: &Git, from: &str, to: &str) -> Option<Vec<String>> {
)
}
/// The ref a step's `on` is matched against in `subject`'s run: the one a
/// push moved, or a run was asked for at. A pull's run and a batch's are at
/// none of the repository's yet, whatever `KNOTLESS_RUN_REF` says.
fn ref_at(subject: &Subject) -> Result<&str, &'static str> {
match (&subject.pull, &subject.batch) {
(Some(_), _) => Err("a pull's run is at no ref of the repository's"),
(None, Some(_)) => Err("a merge queue batch's run is at no ref until it lands"),
(None, None) => Ok(&subject.ref_name),
}
}
/// Whether a step of `check`'s run was left out for the ref it ran at:
/// then a push, at another, would not run as it did, and may not take it.
fn gated(check: &Check) -> bool {
check.steps.iter().any(|s| s.status == status::SKIPPED)
}
/// `step`, as not run.
fn skipped_step(step: &workflow::Step) -> check::Step {
check::Step {
@@ -3761,6 +3791,8 @@ struct Steps<'a> {
/// Where each shared step's repository is checked out, as its steps
/// see it.
step_dirs: &'a [String],
/// The ref a step's `on` is matched against, or why there is none.
at: Result<&'a str, &'static str>,
}
struct Started<'a> {
@@ -4121,6 +4153,12 @@ mod tests {
};
assert!(keeps(&batch(true), &plain));
assert!(!keeps(&batch(false), &plain));
// A step's `on` is matched against a push's ref; a pull's run and
// a batch's are at none, whatever their ref names.
assert_eq!(ref_at(&subject(None, false)), Ok("refs/heads/x"));
let pulled = subject(Some(pull(true, merges())), false);
assert!(ref_at(&pulled).unwrap_err().contains("a pull's run"));
assert!(ref_at(&batch(true)).unwrap_err().contains("batch"));
// Where pushes run elsewhere, a trusted pull's that passes makes
// the base, in place of a push's; never an untrusted pull's, one
// of a workflow no push takes from a pull, nor anywhere else.
crates/knotless-run/tests/run.rs+48−2
@@ -4670,6 +4670,11 @@ fn a_workflow_uses_a_shared_step() {
"secrets: [TOKEN]\nsteps:\n - {{uses: {h}/steps/greet@1, with: {{who: nested}}, secrets: [TOKEN]}}\n"
);
write("outer.yml", &outer);
// One with a step of its own for main alone.
write(
"gated.yml",
"steps:\n - {name: any, run: echo inner any}\n - {name: main, run: echo inner main, on: {refs: [refs/heads/main]}}\n",
);
write(
"loop.yml",
&format!("steps: [{{uses: {h}/steps/loop@1}}]\n"),
@@ -4697,6 +4702,12 @@ fn a_workflow_uses_a_shared_step() {
"{on}steps:\n - run: echo \"own token=${{TOKEN:-none}} other=${{OTHER:-none}}\"\n - uses: {uses}\n with: {{who: world}}\n secrets: [TOKEN]\n - {{name: quiet, uses: {uses}, with: {{who: you, how: hi}}}}\n"
);
std::fs::write(dir.join("shared.yml"), shared).unwrap();
// Steps for some refs: a shared step's `on` holds for each of its
// steps, beside their own.
let gated = format!(
"on: {{push: {{refs: [refs/heads/main, refs/heads/x]}}}}\nsteps:\n - run: echo own any\n - {{name: own main, run: echo own main, on: {{refs: [refs/heads/main]}}}}\n - {{uses: {uses}, with: {{who: x}}, on: {{refs: [refs/heads/x]}}}}\n - {{uses: {h}/steps/gated@1, on: {{refs: [refs/heads/*]}}}}\n"
);
std::fs::write(dir.join("gated.yml"), gated).unwrap();
let using =
|step: &str, more: &str| format!("{on}steps:\n - {{uses: {h}/steps/{step}{more}}}\n");
let workflows = [
@@ -4718,7 +4729,7 @@ fn a_workflow_uses_a_shared_step() {
pin(&mut lock, "greet@1", "1.0.0");
pin(&mut lock, "greet@2", "1.0.0");
pin(&mut lock, "gone@1", "1.1.0");
for step in ["outer", "loop", "d1", "d2", "d3", "d4", "d5"] {
for step in ["outer", "gated", "loop", "d1", "d2", "d3", "d4", "d5"] {
pin(&mut lock, &format!("{step}@1"), "1.0.0");
}
std::fs::write(work.join(".knotless/uses.lock"), lock).unwrap();
@@ -4741,7 +4752,7 @@ fn a_workflow_uses_a_shared_step() {
owner.git(&work, &["push", "-q", "origin", "main"]);
js.ring(&owner.account.did, &ref_key);
let all = [
"shared", "unpinned", "newer", "gone", "nested", "looped", "deep",
"shared", "unpinned", "newer", "gone", "nested", "looped", "deep", "gated",
];
wait_until(|| {
all.iter()
@@ -4789,6 +4800,41 @@ fn a_workflow_uses_a_shared_step() {
let log = log_of(&nested);
assert!(log.contains("hello nested token=*** other=none"), "{log}");
// On main, what is for main runs, and what is for x passes unrun.
let gated = check_of(&runner, &owner, &key, "gated");
assert_eq!(gated["status"], "success", "{gated}");
let steps = gated["steps"].as_array().unwrap().iter();
let unrun = steps.filter(|s| s["status"] == "skipped");
let unrun: Vec<&str> = unrun.map(|s| s["name"].as_str().unwrap()).collect();
assert_eq!(unrun, ["greet / needs", "greet / say", "greet / script"]);
let log = log_of(&gated);
assert!(log.contains("own any") && !log.contains("==> own main\n==> skipped"));
assert!(
log.contains("inner any") && log.contains("inner main"),
"{log}"
);
assert!(!log.contains("hello x"), "{log}");
let why = "==> greet / say\n==> skipped, as refs/heads/main is not refs/heads/x\n";
assert!(log.contains(why), "{log}");
// On x, the other way about.
owner.git(&work, &["push", "-q", "origin", "HEAD:refs/heads/x"]);
js.ring(&owner.account.did, &ref_rkey(&key, "refs/heads/x"));
wait_until(|| {
check_on(&runner, &owner, &key, "refs/heads/x", "gated")["finishedAt"].is_string()
});
let on_x = check_on(&runner, &owner, &key, "refs/heads/x", "gated");
assert_eq!(on_x["status"], "success", "{on_x}");
let log = log_of(&on_x);
assert!(
log.contains("hello x") && log.contains("inner any"),
"{log}"
);
assert!(!log.contains("inner main"), "{log}");
let why = "==> own main\n==> skipped, as refs/heads/x is not refs/heads/main\n";
assert!(log.contains(why), "{log}");
let inner = "==> gated / main\n==> skipped, as refs/heads/x is not refs/heads/main\n";
assert!(log.contains(inner), "{log}");
let said = |wf: &str| {
let check = check_of(&runner, &owner, &key, wf);
assert_eq!(check["status"], "error", "{check}");
docs/design/reusable-workflows.md+9−0
@@ -175,6 +175,15 @@ 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
docs/design/runner.md+21−1
@@ -553,6 +553,24 @@ left behind, and then its cache overlays. The job is released once the
container is gone, before the log and check are written, so a slow PDS
does not hold a job.
### Steps for some refs
A step may name `on: {refs: [...]}`, matched as `on.push.refs` are,
against the ref a push moved or a run asked for by name is at
([reference](../reference/workflows.md#steps-for-some-refs)). A step it
does not match is not run: the log says why under its `==> <step>` line,
and its check lists it `skipped`, as a step after a failure is, the run
still passing. On a shared step, its `on` holds for each step it comes
to, beside their own.
A pull's run and a merge-queue batch's run match no ref, whatever
`KNOTLESS_RUN_REF` holds. A pull's holds its head branch, which a fork
may call `main`, and a batch's holds its base branch, which has not
moved yet: matching either would run a step meant for the default
branch before anything landed there. So a step for `refs/heads/main`
runs only at the push that lands the change. That is why a run that left
a step out is never kept for that push to reuse (below).
### Time limits
`--step-timeout` (1800 seconds) limits each step. A step's own `timeout`
@@ -768,7 +786,9 @@ A pass is not reused when:
- the workflow is `superseded: finish`, since a deploy runs for what it
does, not what it checks;
- the earlier check is no longer `success`;
- the run was asked for by name.
- the run was asked for by name;
- the run left a step out for its ref (`on`), which the push, at another
ref, may run.
A batch can likewise take a trusted pull's pass at the same tree. Passes
stay in the state file after their cache layers are forgotten, so a check
docs/reference/workflows.md+41−3
@@ -40,6 +40,9 @@ steps:
- name: test
run: cargo test
timeout: 30 # minutes
- name: deploy
run: ./deploy.sh
on: {refs: [refs/heads/main, refs/tags/v*]} # default: wherever the workflow runs
```
| Key | Default | Meaning |
@@ -152,6 +155,7 @@ record, which is how readers tell that a pull is waiting for one.
| `with` | with `uses` only: the shared step's inputs, by name |
| `secrets` | with `uses` only: the workflow's `secrets` the shared step is given, by name; none if absent |
| `timeout` | minutes; can only shorten the runner's `--step-timeout`, except in a run asked for by name, where it may go past it |
| `on` | `{refs: [...]}`, the refs the step runs at; see [Steps for some refs](#steps-for-some-refs) |
Under the `docker` executor the checkout is mounted at `/work`, and every
step runs in one container, so a step sees what earlier steps installed.
@@ -161,6 +165,39 @@ checkout as the runner's user.
A step passes on exit 0. One killed, for time or by a cancel, has no exit
code in its check; one ended by a signal otherwise has `-1`.
### Steps for some refs
A step with `on` runs only at the refs its `refs` names, full ref names
matched as `on.push.refs` are: exactly, or as a prefix before a final
`*`. `refs` is required and may not be empty, and `on` takes no other key.
The ref matched is the one a push moved, or the one a run asked for by
name is at. A pull's run and a merge-queue batch's run are at no ref of
the repository's, so a step with `on` runs in neither, whatever
`KNOTLESS_RUN_REF` holds there (a pull's head branch, which may be a
fork's `main`; a batch's base branch, not moved yet).
A step left out is not run: its check lists it as `skipped`, it counts as
passed, and the log says why under its `==> <step name>` line:
- `==> skipped, as refs/heads/x is not refs/heads/main or refs/tags/v*`
- `==> skipped, as a pull's run is at no ref of the repository's`
- `==> skipped, as a merge queue batch's run is at no ref until it lands`
A run that left a step out is not taken by the push that lands its pull
or batch, as other runs that pass are ([merge queue](#the-merge-queue)):
the push runs the workflow itself, with the steps for its ref.
On a step with `uses`, `on` holds for every step the shared step comes
to, and a step in the step file may name its own `on` too: it runs only
where both match, and so on down nested shared steps.
```yaml
steps:
- uses: alice.example.com/ci/publish@1
on: {refs: [refs/heads/main]}
```
### Shared steps
A step with `uses` takes the steps of a file in another repository,
@@ -201,7 +238,7 @@ steps:
| `inputs` | its inputs, by name, each with `description`, `required` (default `false`) and `default` |
| `needs` | command names, each letters, digits, `.`, `_`, `+` or `-`, checked with `command -v` before its steps |
| `secrets` | the names of the secrets a step using it may give it |
| `steps` | required: steps as a workflow's are, with `run`, or with `uses` and `with` and `secrets`, passing on only secrets its own `secrets` names |
| `steps` | required: steps as a workflow's are, with `run`, or with `uses` and `with` and `secrets`, passing on only secrets its own `secrets` names, and each with `on` if it likes |
The version after `@` takes itself and the versions it begins, part by
part: `1` takes `1.0.0` and `1.4.2` but not `10.0.0`, `1.2` takes `1.2.7`,
@@ -249,7 +286,8 @@ adds shared steps from <accounts>`.
A workflow does not parse when a step has both `run` and `uses` or
neither, has `with` or `secrets` without `uses`, passes a secret its
`secrets` does not name, or `uses` is not of the form above. A run is
`secrets` does not name, has an `on` naming no refs, or `uses` is not of
the form above. A run is
filed as an `error` check, before any step runs, saying:
- `<workflow> uses shared steps, but there is no .knotless/uses.lock to pin them`
@@ -287,7 +325,7 @@ own environment instead.
| --- | --- |
| `KNOTLESS_RUN` | `1` |
| `KNOTLESS_RUN_REPO` | the repository, as the runner's command line names it |
| `KNOTLESS_RUN_REF` | the ref, or a pull's head branch |
| `KNOTLESS_RUN_REF` | the ref, a pull's head branch, or a batch's base branch |
| `KNOTLESS_RUN_SHA` | the commit being run; a pull's head, not the merge commit checked out |
| `KNOTLESS_RUN_PULL` | the pull's AT-URI, in a pull's run |
| `KNOTLESS_RUN_BASE` | in a pull's run, the commit its head is merged with, the tip of the branch it is into; in a merge-queue batch's, the tip the batch is built on |