chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A workflow step can run only at some refs
.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 | | ||