~ 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 shared step can be passed a secret under the name it asks for

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:run/secret-names
.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: