chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A shared step can be passed a secret under the name it asks for
.changeset/secret-names.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# A shared step can be passed a secret under the name it asks for | ||
| ||
A `uses` step's `secrets` can be a map from the name the step file asks for to the workflow's own, as `secrets: {SSH_KEY: DEPLOY_SSH_KEY}`: its steps see the value as `SSH_KEY`, and nothing as `DEPLOY_SSH_KEY`. A list still passes each secret under its own name. | ||
crates/knotless/src/uses.rs+65−12
| @@ -15,7 +15,8 @@ | ||
//! A shared step's steps run in the workflow's own container, as its own | ||
//! steps do, named `<step> / <theirs>`. They see the inputs `with` gives | ||
//! them as `INPUT_<NAME>`, and of the run's secrets only those the step | ||
//! passes them, by name, that the step file asks for. | ||
//! passes them, each under the name the step file asks for it by, which | ||
//! may not be the workflow's. | ||
| ||
use crate::env::Ctx; | ||
use crate::git::Git; | ||
| @@ -158,7 +159,7 @@ pub fn parse_step(file: &str, text: &[u8]) -> Result<StepFile> { | ||
if let Some(why) = step.invalid() { | ||
bail!("{file}: step {} {why}", i + 1); | ||
} | ||
let passed = step.secrets.iter().flatten(); | ||
let passed = step.secrets.iter().flat_map(|s| s.values()); | ||
if let Some(name) = passed.into_iter().find(|n| !parsed.secrets.contains(n)) { | ||
bail!( | ||
"{file}: step {} passes the secret {name}, which the file's `secrets` does not name", | ||
| @@ -208,8 +209,9 @@ pub fn expand(step: &Step, used: &Use, file: &StepFile, from: usize) -> Result<V | ||
let var = format!("INPUT_{}", name.to_ascii_uppercase().replace('-', "_")); | ||
env.insert(var, value); | ||
} | ||
// Each by the name the file asks for, from the one the run grants. | ||
let passed = step.secrets.clone().unwrap_or_default(); | ||
if let Some(name) = passed.iter().find(|n| !file.secrets.contains(n)) { | ||
if let Some(name) = passed.keys().find(|n| !file.secrets.contains(n)) { | ||
bail!("{uses} does not ask for the secret {name}"); | ||
} | ||
// Its `on`, and those of the shared steps it came from, hold for each. | ||
| @@ -240,8 +242,9 @@ pub fn expand(step: &Step, used: &Use, file: &StepFile, from: usize) -> Result<V | ||
steps.push(ours(&theirs.name, run, theirs.timeout, on)); | ||
continue; | ||
} | ||
// What it passes by the file's names, it passes from the run's. | ||
let given = theirs.secrets.iter().flatten(); | ||
let given = given.filter(|n| passed.contains(n)).cloned(); | ||
let given = given.filter_map(|(to, from)| Some((to.clone(), passed.get(from)?.clone()))); | ||
steps.push(Step { | ||
name: format!("{} / {}", step.name, theirs.name), | ||
secrets: Some(given.collect()), | ||
| @@ -579,6 +582,21 @@ mod tests { | ||
assert_eq!(nested.unwrap().steps[0].name, "c"); | ||
let both = parse_step("x.yml", b"steps:\n - {run: x, uses: a/b/c@1}\n").unwrap_err(); | ||
assert!(both.to_string().contains("step 1 has both"), "{both}"); | ||
let mapped = parse_step( | ||
"x.yml", | ||
b"secrets: [K]\nsteps:\n - {uses: a/b/c@1, secrets: {INNER: K}}\n", | ||
); | ||
let mapped = mapped.unwrap().steps[0].secrets.clone().unwrap(); | ||
assert_eq!(mapped["INNER"], "K"); | ||
let unmapped = parse_step( | ||
"x.yml", | ||
b"secrets: [K]\nsteps:\n - {uses: a/b/c@1, secrets: {K: OTHER}}\n", | ||
); | ||
let unmapped = unmapped.unwrap_err().to_string(); | ||
assert!( | ||
unmapped.contains("secret OTHER, which the file's"), | ||
"{unmapped}" | ||
); | ||
let unasked = parse_step("x.yml", b"steps:\n - {uses: a/b/c@1, secrets: [K]}\n"); | ||
assert!( | ||
unasked | ||
| @@ -599,7 +617,17 @@ mod tests { | ||
assert!(odd.to_string().starts_with("x.yml: "), "{odd}"); | ||
} | ||
| ||
fn step(with: &[(&str, &str)], secrets: Option<Vec<String>>) -> Step { | ||
/// Secrets passed, each as `(the step file's name, the run's)`. | ||
fn pass(pairs: &[(&str, &str)]) -> Option<BTreeMap<String, String>> { | ||
Some( | ||
pairs | ||
.iter() | ||
.map(|(a, b)| (a.to_string(), b.to_string())) | ||
.collect(), | ||
) | ||
} | ||
| ||
fn step(with: &[(&str, &str)], secrets: Option<BTreeMap<String, String>>) -> Step { | ||
Step { | ||
name: "wait".into(), | ||
uses: Some("alice.test/steps/wait-for-checks@1".into()), | ||
| @@ -619,7 +647,7 @@ mod tests { | ||
b"inputs:\n workflows: {required: true}\n only-on: {default: refs/heads/main}\n extra: {}\nneeds: [curl]\nsecrets: [TOKEN]\nsteps:\n - {name: wait, run: echo $INPUT_WORKFLOWS, timeout: 30}\n", | ||
) | ||
.unwrap(); | ||
let passed = Some(vec!["TOKEN".to_string()]); | ||
let passed = pass(&[("TOKEN", "TOKEN")]); | ||
let steps = expand( | ||
&step(&[("workflows", "test check")], passed), | ||
&used(), | ||
| @@ -635,12 +663,26 @@ mod tests { | ||
assert_eq!(s.env["INPUT_ONLY_ON"], "refs/heads/main"); | ||
assert_eq!(s.env["INPUT_EXTRA"], ""); | ||
assert_eq!(s.timeout, Some(30)); | ||
assert!(s.sees("TOKEN") && !s.sees("OTHER")); | ||
let granted = |names: &[&str]| -> Vec<(String, String)> { | ||
let value = |n: &&str| (n.to_string(), format!("{n} value")); | ||
names.iter().map(value).collect() | ||
}; | ||
let token = granted(&["TOKEN"]); | ||
assert_eq!(s.seen(&granted(&["TOKEN", "OTHER"])), token); | ||
assert_eq!(s.from, Some(0)); | ||
assert!(Step::default().sees("ANY")); | ||
| ||
let none = expand(&step(&[("workflows", "t")], None), &used(), &file, 0).unwrap(); | ||
assert!(!none[1].sees("TOKEN")); | ||
assert!(none[1].seen(&token).is_empty()); | ||
| ||
// A secret passed under another name is seen by that name alone. | ||
let renamed = pass(&[("TOKEN", "DEPLOY_TOKEN")]); | ||
let renamed = step(&[("workflows", "t")], renamed); | ||
let theirs = expand(&renamed, &used(), &file, 0).unwrap(); | ||
let seen = theirs[1].seen(&granted(&["DEPLOY_TOKEN", "TOKEN"])); | ||
assert_eq!( | ||
seen, | ||
[("TOKEN".to_string(), "DEPLOY_TOKEN value".to_string())] | ||
); | ||
let plain = StepFile { | ||
needs: vec![], | ||
..file.clone() | ||
| @@ -655,13 +697,24 @@ mod tests { | ||
b"secrets: [TOKEN, MORE]\nsteps:\n - {uses: b.test/s/inner@1, with: {x: y}, secrets: [TOKEN, MORE]}\n", | ||
) | ||
.unwrap(); | ||
let passed = Some(vec!["TOKEN".to_string()]); | ||
let passed = pass(&[("TOKEN", "TOKEN")]); | ||
let theirs = expand(&step(&[], passed), &used(), &using, 3).unwrap(); | ||
assert_eq!(theirs[0].name, "wait / inner"); | ||
assert_eq!(theirs[0].uses.as_deref(), Some("b.test/s/inner@1")); | ||
assert_eq!(theirs[0].secrets, Some(vec!["TOKEN".to_string()])); | ||
assert_eq!(theirs[0].secrets, pass(&[("TOKEN", "TOKEN")])); | ||
assert_eq!(theirs[0].with["x"], "y"); | ||
| ||
// One it passes under another name maps from its parent's names, | ||
// so it comes from the name the run grants it by. | ||
let renaming = parse_step( | ||
"u.yml", | ||
b"secrets: [KEY, MORE]\nsteps:\n - {uses: b.test/s/inner@1, secrets: {INNER: KEY, ALSO: MORE}}\n", | ||
) | ||
.unwrap(); | ||
let passed = pass(&[("KEY", "DEPLOY_KEY")]); | ||
let theirs = expand(&step(&[], passed), &used(), &renaming, 3).unwrap(); | ||
assert_eq!(theirs[0].secrets, pass(&[("INNER", "DEPLOY_KEY")])); | ||
| ||
// 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( | ||
| @@ -687,7 +740,7 @@ mod tests { | ||
assert!(wrong(step(&[], None)).contains("needs the input workflows")); | ||
let unknown = wrong(step(&[("workflows", "t"), ("nope", "x")], None)); | ||
assert!(unknown.contains("has no input nope"), "{unknown}"); | ||
let unasked = wrong(step(&[("workflows", "t")], Some(vec!["OTHER".into()]))); | ||
let unasked = wrong(step(&[("workflows", "t")], pass(&[("OTHER", "TOKEN")]))); | ||
assert!( | ||
unasked.contains("does not ask for the secret OTHER"), | ||
"{unasked}" | ||
crates/knotless/src/workflow.rs+101−11
| @@ -35,7 +35,9 @@ use crate::git::Git; | ||
use crate::read; | ||
use anyhow::{Result, bail}; | ||
use serde::Deserialize; | ||
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor}; | ||
use std::collections::BTreeMap; | ||
use std::fmt; | ||
| ||
pub const DIR: &str = ".knotless/workflows"; | ||
| ||
| @@ -195,11 +197,14 @@ pub struct Step { | ||
/// The inputs a shared step is given, by name. | ||
#[serde(default)] | ||
pub with: BTreeMap<String, String>, | ||
/// On a shared step, the workflow's secrets it is given. On the steps | ||
/// it is put in place of, the only ones they see; on any other step, | ||
/// `None`, and it sees every secret the run is granted. | ||
#[serde(default)] | ||
pub secrets: Option<Vec<String>>, | ||
/// On a shared step, the secrets it passes, each by the name its step | ||
/// file asks for it by, from the name it has where the step is: the | ||
/// workflow's, or that of the step file using it. A list passes each | ||
/// under its own name. On the steps it is put in place of, the only | ||
/// ones they see, each from the name the run is granted it by; on any | ||
/// other step, `None`, and it sees every secret the run is granted. | ||
#[serde(default, deserialize_with = "passed")] | ||
pub secrets: Option<BTreeMap<String, String>>, | ||
/// Variables of this step's own, as a shared step's inputs; never in | ||
/// a workflow file. | ||
#[serde(skip)] | ||
| @@ -222,6 +227,54 @@ pub struct Step { | ||
pub within: Vec<StepOn>, | ||
} | ||
| ||
/// A shared step's `secrets`: a list of names, each passed under its own, | ||
/// or a map from the name its step file asks for to the one passed as it. | ||
fn passed<'de, D: Deserializer<'de>>( | ||
de: D, | ||
) -> std::result::Result<Option<BTreeMap<String, String>>, D::Error> { | ||
let pairs = de.deserialize_any(Passed)?; | ||
let mut passed = BTreeMap::new(); | ||
for (to, from) in pairs { | ||
if passed.insert(to.clone(), from).is_some() { | ||
return Err(de::Error::custom(format!("passes the secret {to} twice"))); | ||
} | ||
} | ||
Ok(Some(passed)) | ||
} | ||
| ||
/// Reads a shared step's `secrets` as pairs, the step file's name first. | ||
struct Passed; | ||
| ||
impl<'de> Visitor<'de> for Passed { | ||
type Value = Vec<(String, String)>; | ||
| ||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { | ||
f.write_str("a list of secrets, or a map from the names a step file asks for to secrets") | ||
} | ||
| ||
fn visit_seq<A: SeqAccess<'de>>( | ||
self, | ||
mut seq: A, | ||
) -> std::result::Result<Self::Value, A::Error> { | ||
let mut pairs = Vec::new(); | ||
while let Some(name) = seq.next_element::<String>()? { | ||
pairs.push((name.clone(), name)); | ||
} | ||
Ok(pairs) | ||
} | ||
| ||
fn visit_map<A: MapAccess<'de>>( | ||
self, | ||
mut map: A, | ||
) -> std::result::Result<Self::Value, A::Error> { | ||
let mut pairs = Vec::new(); | ||
while let Some(pair) = map.next_entry::<String, String>()? { | ||
pairs.push(pair); | ||
} | ||
Ok(pairs) | ||
} | ||
} | ||
| ||
/// Where a step runs: at the refs it names. | ||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)] | ||
#[serde(deny_unknown_fields)] | ||
| @@ -231,11 +284,17 @@ pub struct StepOn { | ||
} | ||
| ||
impl Step { | ||
/// Whether it sees the secret `name`, of those the run is granted. | ||
pub fn sees(&self, name: &str) -> bool { | ||
self.secrets | ||
.as_ref() | ||
.is_none_or(|only| only.iter().any(|n| n == name)) | ||
/// Of the secrets the run is granted, by the workflow's names, those | ||
/// it sees, by the names it sees them by. | ||
pub fn seen(&self, granted: &[(String, String)]) -> Vec<(String, String)> { | ||
let Some(only) = &self.secrets else { | ||
return granted.to_vec(); | ||
}; | ||
let value = |from: &String| granted.iter().find(|(n, _)| n == from); | ||
let given = only | ||
.iter() | ||
.filter_map(|(to, from)| Some((to, &value(from)?.1))); | ||
given.map(|(to, v)| (to.clone(), v.clone())).collect() | ||
} | ||
| ||
/// Why it is not a step: it has both `run` and `uses`, or neither, or | ||
| @@ -390,7 +449,7 @@ pub fn parse(file: &str, text: &[u8]) -> Result<Workflow> { | ||
bail!("{file}: step {} {why}", i + 1); | ||
} | ||
if let Some(passed) = &step.secrets { | ||
let unasked = passed.iter().find(|n| !workflow.secrets.contains(n)); | ||
let unasked = passed.values().find(|n| !workflow.secrets.contains(n)); | ||
if let Some(name) = unasked { | ||
bail!( | ||
"{file}: step {} passes the secret {name}, which the workflow's `secrets` does not name", | ||
| @@ -523,6 +582,37 @@ mod tests { | ||
assert!(with.contains("only a step with `uses`"), "{with}"); | ||
let secret = wrong("steps:\n - {uses: a/b/c@1, secrets: [KEY]}\n"); | ||
assert!(secret.contains("does not name"), "{secret}"); | ||
let kept = |w: &Workflow| w.steps[0].secrets.clone().unwrap().into_iter().collect(); | ||
let same: Vec<(String, String)> = kept(&w); | ||
assert_eq!(same, [("KEY".to_string(), "KEY".to_string())]); | ||
| ||
// A map passes the workflow's secret under the name the step file | ||
// asks for. | ||
let text = | ||
"secrets: [DEPLOY_KEY]\nsteps:\n - {uses: a/b/c@1, secrets: {SSH_KEY: DEPLOY_KEY}}\n"; | ||
let renamed: Vec<(String, String)> = kept(&parse("d.yml", text.as_bytes()).unwrap()); | ||
assert_eq!(renamed, [("SSH_KEY".to_string(), "DEPLOY_KEY".to_string())]); | ||
let unnamed = wrong("secrets: [A]\nsteps:\n - {uses: a/b/c@1, secrets: {A: B}}\n"); | ||
assert!( | ||
unnamed.contains("passes the secret B, which the workflow's"), | ||
"{unnamed}" | ||
); | ||
let twice = wrong("secrets: [A]\nsteps:\n - {uses: a/b/c@1, secrets: [A, A]}\n"); | ||
assert!(twice.contains("passes the secret A twice"), "{twice}"); | ||
let odd = wrong("steps:\n - {uses: a/b/c@1, secrets: 3}\n"); | ||
assert!(odd.contains("a list of secrets, or a map"), "{odd}"); | ||
| ||
// A step given none sees every secret granted; one given some, | ||
// only those, by the names it is given them under. | ||
let granted = [("DEPLOY_KEY".to_string(), "v".to_string())]; | ||
assert_eq!(Step::default().seen(&granted), granted); | ||
let pairs = [("SSH_KEY", "DEPLOY_KEY"), ("NO", "NONE")]; | ||
let step = Step { | ||
secrets: Some(pairs.map(|(a, b)| (a.into(), b.into())).into()), | ||
..Step::default() | ||
}; | ||
let seen = [("SSH_KEY".to_string(), "v".to_string())]; | ||
assert_eq!(step.seen(&granted), seen); | ||
let shape = wrong("steps:\n - {uses: a/b@1}\n"); | ||
assert!(shape.contains("d.yml: step 1: `uses: a/b@1`"), "{shape}"); | ||
} | ||
crates/knotless-run/src/executor.rs+15−0
| @@ -769,6 +769,21 @@ mod tests { | ||
assert_eq!(args(&cmd).join(" "), "docker exec -e KEY c1 sh -ec make"); | ||
let env: Vec<_> = cmd.as_std().get_envs().collect(); | ||
assert!(env.contains(&("KEY".as_ref(), Some("k".as_ref())))); | ||
// A secret passed to a shared step under the name its step file | ||
// asks for reaches the client by that name alone. | ||
let step = knotless::workflow::Step { | ||
secrets: Some([("SSH_KEY".to_string(), "DEPLOY_KEY".to_string())].into()), | ||
..Default::default() | ||
}; | ||
let granted = [("DEPLOY_KEY".to_string(), "k".to_string())]; | ||
let cmd = docker.command("make", &step.seen(&granted)); | ||
assert_eq!( | ||
args(&cmd).join(" "), | ||
"docker exec -e SSH_KEY c1 sh -ec make" | ||
); | ||
let env: Vec<_> = cmd.as_std().get_envs().collect(); | ||
assert!(env.contains(&("SSH_KEY".as_ref(), Some("k".as_ref())))); | ||
assert!(!env.iter().any(|(k, _)| *k == "DEPLOY_KEY")); | ||
} | ||
| ||
/// A stand-in docker client, in `dir`, that writes what it is asked | ||
crates/knotless-run/src/lib.rs+4−2
| @@ -3062,11 +3062,13 @@ impl Runner { | ||
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())); | ||
let seen = granted.iter().filter(|(name, _)| step.sees(name)).cloned(); | ||
// Each by the name it sees it by, a shared step's or the | ||
// workflow's, from the name the run is granted it by. | ||
let seen = step.seen(granted); | ||
let from = step | ||
.from | ||
.map(|i| ("KNOTLESS_STEP_DIR".into(), step_dirs[i].clone())); | ||
let given = seen.clone().next().is_some(); | ||
let given = !seen.is_empty(); | ||
let own: Vars = own.chain(seen).chain(from).collect(); | ||
// Beside shared steps, what a step given secrets leaves running | ||
// is ended once it ends, so none holds them for the next. | ||
crates/knotless-run/tests/run.rs+55−2
| @@ -4681,6 +4681,17 @@ 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 asking for a secret by a name of its own, which says how long | ||
// what it sees is, as the log hides the value; and one passing it a | ||
// secret of its own under that name. | ||
write( | ||
"keyed.yml", | ||
"secrets: [KEY]\nsteps:\n - run: echo \"key=${KEY:-none} len=${#KEY} token=${TOKEN:-none} other=${OTHER:-none}\"\n", | ||
); | ||
let renaming = format!( | ||
"secrets: [INNER]\nsteps:\n - {{uses: {h}/steps/keyed@1, secrets: {{KEY: INNER}}}}\n" | ||
); | ||
write("renaming.yml", &renaming); | ||
// One with a step of its own for main alone. | ||
write( | ||
"gated.yml", | ||
| @@ -4728,6 +4739,17 @@ fn a_workflow_uses_a_shared_step() { | ||
("nested", using("outer@1", ", secrets: [TOKEN]")), | ||
("looped", using("loop@1", "")), | ||
("deep", using("d1@1", "")), | ||
("renamed", using("keyed@1", ", secrets: {KEY: OTHER}")), | ||
( | ||
"renamings", | ||
using("renaming@1", ", secrets: {INNER: TOKEN}"), | ||
), | ||
( | ||
"unrenamed", | ||
format!( | ||
"on: {{push: {{refs: [refs/heads/main]}}}}\nsecrets: [MISSING]\nsteps:\n - {{uses: {h}/steps/keyed@1, secrets: {{KEY: MISSING}}}}\n" | ||
), | ||
), | ||
]; | ||
for (name, text) in &workflows { | ||
std::fs::write(dir.join(format!("{name}.yml")), text).unwrap(); | ||
| @@ -4740,7 +4762,10 @@ 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", "gated", "loop", "d1", "d2", "d3", "d4", "d5"] { | ||
let pinned_steps = [ | ||
"outer", "gated", "loop", "d1", "d2", "d3", "d4", "d5", "keyed", "renaming", | ||
]; | ||
for step in pinned_steps { | ||
pin(&mut lock, &format!("{step}@1"), "1.0.0"); | ||
} | ||
std::fs::write(work.join(".knotless/uses.lock"), lock).unwrap(); | ||
| @@ -4763,7 +4788,17 @@ 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", "gated", | ||
"shared", | ||
"unpinned", | ||
"newer", | ||
"gone", | ||
"nested", | ||
"looped", | ||
"deep", | ||
"gated", | ||
"renamed", | ||
"renamings", | ||
"unrenamed", | ||
]; | ||
wait_until(|| { | ||
all.iter() | ||
| @@ -4811,6 +4846,24 @@ fn a_workflow_uses_a_shared_step() { | ||
let log = log_of(&nested); | ||
assert!(log.contains("hello nested token=*** other=none"), "{log}"); | ||
| ||
// A secret passed under the name the step file asks for is seen by | ||
// that name alone: OTHER's four letters as KEY, and no OTHER. | ||
let renamed = check_of(&runner, &owner, &key, "renamed"); | ||
assert_eq!(renamed["status"], "success", "{renamed}"); | ||
let log = log_of(&renamed); | ||
assert!(log.contains("key=*** len=4 token=none other=none"), "{log}"); | ||
// Through a step file passing on its own name, TOKEN's six as KEY. | ||
let renamings = check_of(&runner, &owner, &key, "renamings"); | ||
assert_eq!(renamings["status"], "success", "{renamings}"); | ||
let log = log_of(&renamings); | ||
assert!(log.contains("key=*** len=6 token=none other=none"), "{log}"); | ||
// One the runner does not grant is named as the workflow names it. | ||
let unrenamed = check_of(&runner, &owner, &key, "unrenamed"); | ||
assert_eq!(unrenamed["status"], "success", "{unrenamed}"); | ||
let log = log_of(&unrenamed); | ||
assert!(log.contains("==> MISSING is not granted to"), "{log}"); | ||
assert!(log.contains("key=none len=0"), "{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}"); | ||
docs/design/reusable-workflows.md+20−1
| @@ -168,7 +168,8 @@ 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. | ||
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 | ||
| @@ -201,6 +202,24 @@ A `uses` step lists the secrets it passes. Each must be one the workflow's | ||
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 | ||
docs/reference/workflows.md+16−4
| @@ -153,7 +153,7 @@ record, which is how readers tell that a pull is waiting for one. | ||
| `run` | a shell script, run with `sh -ec`, so any command in it that fails fails the step; a step has this or `uses` | | ||
| `uses` | a [shared step](#shared-steps), `<owner>/<repo>/<step>@<version>`, put in this step's place | | ||
| `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 | | ||
| `secrets` | with `uses` only: the workflow's `secrets` the shared step is given, as a list of names, each passed under its own, or a map from the name the step file asks for to the workflow's (`{SSH_KEY: DEPLOY_SSH_KEY}`); 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) | | ||
| ||
| @@ -232,6 +232,15 @@ steps: | ||
secrets: [TOKEN] | ||
``` | ||
| ||
```yaml | ||
# a workflow passing its own secret under the name the step file asks for | ||
secrets: [CI_TOKEN] | ||
steps: | ||
- uses: alice.example.com/steps/wait-for-checks@1 | ||
with: {workflows: test check} | ||
secrets: {TOKEN: CI_TOKEN} # seen as TOKEN, and not as CI_TOKEN | ||
``` | ||
| ||
| Step file key | Meaning | | ||
| --- | --- | | ||
| `description` | what it does | | ||
| @@ -266,8 +275,10 @@ step of the file with `uses` is put in place the same way, named | ||
| ||
- the inputs as `INPUT_<NAME>`, the name upper-cased with `-` as `_`: the | ||
value `with` gives, else the default, else empty; | ||
- of the run's secrets, only those the step passes, and in a nested | ||
step, only those of them the step using it passes on; | ||
- of the run's secrets, only those the step passes, each under the name | ||
the step file asks for it by and not the workflow's, and in a nested | ||
step, only those of them the step using it passes on, its `secrets` | ||
mapping from that step file's names; | ||
- `KNOTLESS_STEP_DIR`, the step's repository at the locked commit, | ||
checked out beside the checkout, read-only under docker at | ||
`/steps/<n>`. | ||
| @@ -286,7 +297,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, has an `on` naming no refs, or `uses` is not of | ||
`secrets` does not name, passes one name twice, 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: | ||