Skip to content

Commit

Merge branch 'worktree-agent-af75396ad4c6ee7a8' into claude/cool-newton-7nm8r9

Claudeagentcommitted Parentsef6c4dc6137a26Browse files
8 files+619−1010/8 viewed
+52−2
5959 | `run:` with `bash`, `sh`, `python` or a custom shell | The same. |
6060 | JavaScript actions (`uses: owner/repo@v7`, `owner/repo/path@v7`) | Fetched from that repository on g1t when g1t has it and your repository may use it, otherwise from GitHub, and run as they are, on Node 24, the runtime current actions declare. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). |
6161 | Composite actions | The same. |
62−| Reusable workflows (`jobs.<id>.uses: ./.g1t/workflows/build.yml`, or `owner/repo/.g1t/workflows/build.yml@v1` in another repository) | The same: `with:` inputs, `secrets:` by name or `secrets: inherit`, `on.workflow_call` outputs, and nesting up to four deep. `.github/workflows/…` finds the workflow under `.g1t/` after the move. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). |
62+| Reusable workflows (`jobs.<id>.uses: ./.g1t/workflows/build.yml`, or `owner/repo/.g1t/workflows/build.yml@v1` in another repository) | The same: `with:` inputs checked against their `type`, `secrets:` by name or `secrets: inherit`, `on.workflow_call` outputs, `strategy.matrix` on the calling job (one call for each combination), `concurrency` on the calling job, and up to ten levels of workflows with at most 50 different reusable workflows in a run. `.github/workflows/…` finds the workflow under `.g1t/` after the move. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). |
6363 | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
6464 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. Step summaries show on the run's page; see [job summaries](#job-summaries). |
6565 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run, and [masked](#masking-secrets) values stay hidden. |
225225 with the calling run's `github` context: `actions/checkout` checks out the
226226 calling repository.
227227
228+A secret passed by name that the called workflow does not declare under
229+`on.workflow_call.secrets` fails the calling job: `Invalid secret: … is
230+not defined in …`.
231+
232+### Inputs for a called workflow
233+
234+The values under `with:` are checked against the `type` each input has
235+under `on.workflow_call.inputs`, before anything runs:
236+
237+- A `boolean` input takes `true` or `false`, and a `number` input a
238+ number. Text such as `'true'` or a job's output (outputs are always
239+ text) fails the calling job: pass `${{ fromJSON(needs.build.outputs.count) }}`
240+ to give its value.
241+- A `string` input takes text; a number or a boolean becomes its text.
242+- An input you leave out takes its `default`, else `false`, `0` or `''`
243+ by its type. A `required: true` input you leave out fails the calling
244+ job.
245+- A name under `with:` that the called workflow does not declare fails the
246+ calling job: `Invalid input: … is not defined in …`.
247+
248+### A matrix of calls
249+
250+A job that calls a reusable workflow may have a `strategy`. Each
251+combination of its matrix calls the workflow once, with `matrix` read in
252+`with:` and `secrets:`:
253+
254+```yaml
255+jobs:
256+ test:
257+ strategy:
258+ matrix:
259+ node: [20, 22]
260+ max-parallel: 1
261+ uses: ./.g1t/workflows/test.yml
262+ with:
263+ node-version: ${{ matrix.node }}
264+```
265+
266+The called workflow's jobs are named under the combination's call:
267+`test (20) / unit`, `test (22) / unit`. `max-parallel` limits how many
268+calls run at a time, and with `fail-fast` (the default) a call that fails
269+cancels the calls still running or waiting. `concurrency` on the calling
270+job holds its group while the called workflow runs, as a job's own does.
271+
272+A run connects at most ten levels of workflows: the workflow it started
273+from and nine levels of reusable workflows below it. It calls at most 50
274+different reusable workflows; a workflow called again, as by each
275+combination of a matrix, counts once. A call past either limit fails the
276+calling job and says why.
277+
228278 ## Releases
229279
230280 Workflows with `on: release` start when a release changes, at the commit
9821032 | A job | Its status, its name and how long it took. Click it to open its steps and logs. |
9831033 | A job that deploys | Its environment's address (`environment.url`), as a link, under its name. |
9841034 | A matrix | One node with how many jobs it made (`test · 6 jobs`) and how they stand together. Click it to list each combination; click one to open it. |
985−| A reusable workflow | A box under the job that calls it, named with the called file, holding the called workflow's jobs laid out the same way. The jobs that need the calling job connect to the box. |
1035+| A reusable workflow | A box under the job that calls it, named with the called file, holding the called workflow's jobs laid out the same way; a matrix of calls shares one box, each call's jobs named with its combination. The jobs that need the calling job connect to the box. |
9861036
9871037 Connectors run from a job to each job that needs it. They are red from a
9881038 job that failed, and move while the job they lead to is running (they
+29−0
103103 assert.equal(open.edges[0]!.path, closed.edges[0]!.path);
104104 });
105105
106+test("a matrix's calls share the caller's box, each call's jobs named by its combination", () => {
107+ const uses = "./.g1t/workflows/build.yml";
108+ const jobs = [
109+ job("build", [], { name: "Build (linux)", uses }),
110+ job("build", [], { name: "Build (mac)", uses }),
111+ job("build[0]/test", [], { name: "Build (linux) / test" }),
112+ job("build[1]/test", [], { name: "Build (mac) / test" }),
113+ job("deploy", ["build"]),
114+ ];
115+ const units = groupJobs(jobs);
116+ assert.deepEqual(
117+ units.map((unit) => [unit.kind, unit.key]),
118+ [
119+ ["call", "build"],
120+ ["job", "deploy"],
121+ ],
122+ );
123+ const call = units[0]!;
124+ assert.ok(call.kind === "call");
125+ assert.equal(call.callers.length, 2);
126+ assert.deepEqual(
127+ call.units.map((unit) => [unit.key, unit.label]),
128+ [
129+ ["build[0]/test", "Build (linux) / test"],
130+ ["build[1]/test", "Build (mac) / test"],
131+ ],
132+ );
133+});
134+
106135 test("a called workflow's jobs sit in a box under the job calling it", () => {
107136 const jobs = [
108137 job("build", [], { name: "Build", uses: "./.g1t/workflows/build.yml" }),
+6−2
6464 const rest = job.key.slice(prefix.length);
6565 if (!rest) continue;
6666 const segment = rest.split("/")[0]!;
67− const key = prefix + segment;
67+ // A matrix's calls each have their jobs under `build[i]/`, in `build`'s box.
68+ const key = prefix + segment.replace(/\[\d+\]$/, "");
6869 if (!direct.has(key)) {
6970 order.push(key);
7071 direct.set(key, []);
7980 const label = own.length > 0 ? within(own.length > 1 ? baseName(own[0]!.name) : own[0]!.name, parent) : key.slice(prefix.length);
8081 if (inner.length > 0 || own.some((job) => job.uses)) {
8182 const callerName = own.length > 0 ? (own.length > 1 ? baseName(own[0]!.name) : own[0]!.name) : label;
83+ // One call, or one for each combination of the caller's matrix,
84+ // whose jobs keep the combination in their names.
85+ const calls = unique(inner.map((job) => job.key.slice(0, job.key.indexOf("/", key.length) + 1)));
8286 return {
8387 kind: "call",
8488 key,
8690 needs,
8791 uses: own.find((job) => job.uses)?.uses ?? null,
8892 callers: own,
89− units: groupJobs(inner, `${key}/`, callerName),
93+ units: calls.flatMap((under) => groupJobs(inner, under, under === `${key}/` ? callerName : baseName(callerName))),
9094 };
9195 }
9296 if (own.length > 1) return { kind: "matrix", key, label, needs, jobs: own };
+1−8
454454 );
455455 }
456456 }
457− if uses.is_some() && spec.contains_key("strategy") {
458− note(
459− Severity::Warning,
460− Some(id),
461− "`strategy` on a job that calls a reusable workflow: g1t runs the called workflow once, not once for each combination of the matrix.".to_owned(),
462− );
463− }
464457 if spec.contains_key("services") {
465458 note(
466459 Severity::Info,
709702 };
710703 assert!(warned("arm", "runs on x64"));
711704 assert!(warned("arm", "stopped at 60 minutes"));
712− assert!(warned("call", "runs the called workflow once"));
705+ assert!(!workflow.notes.iter().any(|n| n.job.as_deref() == Some("call") && n.severity == Severity::Warning));
713706 assert!(!workflow.notes.iter().any(|n| matches!(n.job.as_deref(), Some("short" | "mine")) && n.severity == Severity::Warning));
714707 let artifacts = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ uses: actions/upload-artifact@v4 }]").unwrap();
715708 assert!(artifacts.notes.iter().any(|n| n.message.contains("5 GiB each")));
+402−87
254254 }
255255
256256 /// The key under `jobs:` a job belongs to: a called workflow's jobs
257−/// (`build/test`) run again with the job that calls it (`build`).
257+/// (`build/test`, or `build[1]/test` for a matrix's second call) run again
258+/// with the job that calls it (`build`).
258259 pub fn top_key(key: &str) -> &str {
259− key.split('/').next().unwrap_or(key)
260+ let first = key.split('/').next().unwrap_or(key);
261+ first.split('[').next().unwrap_or(first)
262+}
263+
264+/// What the keys of the jobs a caller's call makes start with: the
265+/// caller's key, and for a matrix the combination's index after it
266+/// (`build[1]`), so each combination's call has jobs of its own. A job id
267+/// has no `[`, so the two never meet.
268+fn call_prefix(key: &str, index: Option<usize>) -> String {
269+ match index {
270+ Some(index) => format!("{key}[{index}]"),
271+ None => key.to_owned(),
272+ }
273+}
274+
275+/// Whether a run may call one more reusable workflow, `file`, `depth`
276+/// levels below the workflow it started from, having called `called`
277+/// already (one name per call; repeats are one workflow). As on GitHub: ten
278+/// levels of workflows at most, the top one and nine reusable ones below
279+/// it, and 50 different reusable workflows in a run.
280+fn call_allowed(depth: u64, called: &[&str], file: &str) -> std::result::Result<(), String> {
281+ if depth > MAX_CALL_DEPTH {
282+ return Err(format!(
283+ "`{file}` would be the {}th level of workflows: a run connects at most ten, the workflow it started from and nine levels of reusable workflows below it.",
284+ depth + 1
285+ ));
286+ }
287+ let mut unique: Vec<&str> = called.to_vec();
288+ unique.push(file);
289+ unique.sort_unstable();
290+ unique.dedup();
291+ if unique.len() > MAX_CALLED_WORKFLOWS {
292+ return Err(format!("Calling `{file}` would make {} reusable workflows in this run: a run calls at most {MAX_CALLED_WORKFLOWS} different ones.", unique.len()));
293+ }
294+ Ok(())
260295 }
261296
297+/// What a job is shown as: its `name` (else its id), under the job whose
298+/// call made it (`caller`), read with its matrix when it has an
299+/// expression, else with the matrix's values after it.
300+fn shown_name(job: &workflow::Job, caller: Option<&str>, combination: &Map<String, Value>, scope: &Scope) -> String {
301+ let base = job.name.clone().unwrap_or(job.id.clone());
302+ let base = match caller {
303+ Some(caller) => format!("{caller} / {base}"),
304+ None => base,
305+ };
306+ if expr::has_expression(&base) {
307+ expr::interpolate(&base, scope).unwrap_or(base)
308+ } else if job.matrix.is_some() {
309+ matrix::job_name(&base, combination)
310+ } else {
311+ base
312+ }
313+}
314+
262315 /// Debug logging for a job, as a re-run with it turned on gives GitHub's:
263316 /// `RUNNER_DEBUG=1` and `runner.debug`, and `ACTIONS_STEP_DEBUG` and
264317 /// `ACTIONS_RUNNER_DEBUG` set, which the runner reads as the secrets of
279332 self.call.as_deref().and_then(|call| serde_json::from_str(call).ok())
280333 }
281334
335+ /// For a job that calls a reusable workflow: what the keys of the jobs
336+ /// its call makes start with (`call_prefix`).
337+ pub fn call_prefix(&self) -> Option<String> {
338+ let call = self.call().filter(|call| call["role"] == "caller")?;
339+ Some(call["prefix"].as_str().unwrap_or(&self.key).to_owned())
340+ }
341+
342+ /// A job that calls a reusable workflow and has not called it yet: it
343+ /// waits for room under its matrix's `max-parallel`, or in its
344+ /// concurrency group.
345+ pub fn holds_call(&self) -> bool {
346+ self.status == "waiting" && self.call_prefix().is_some()
347+ }
348+
282349 /// For a job of a called workflow: that workflow, the job's own id in
283350 /// it, and the job.
284351 pub fn callee(&self) -> Option<(Workflow, workflow::Job, Value)> {
293360 /// (name in `needs`, key of the jobs).
294361 type Unit = (String, workflow::Job, Vec<(String, String)>);
295362
296−/// How deep reusable workflows may call one another, as on GitHub.
297−const MAX_CALL_DEPTH: u64 = 4;
363+/// How many levels of reusable workflows may sit below the workflow a run
364+/// started from, as on GitHub (ten levels of workflows in all).
365+const MAX_CALL_DEPTH: u64 = 9;
366+/// How many different reusable workflows a run may call, as on GitHub.
367+const MAX_CALLED_WORKFLOWS: usize = 50;
298368
299369 /// What the jobs of one key came to, for `needs.<key>`.
300370 fn key_result(rows: &[&JobRow]) -> &'static str {
755825 let key_failed = |key: &str| key_result(&jobs.iter().filter(|row| row.key == key).collect::<Vec<_>>()) == "failure";
756826 for (key, job, needs) in &units {
757827 let rows: Vec<&JobRow> = jobs.iter().filter(|row| &row.key == key).collect();
758− if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting") {
828+ if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting" && !row.holds_call()) {
759829 continue;
760830 }
761831 let needed: Vec<(&String, Vec<&JobRow>)> =
769839 }
770840 // A job that called a workflow finishes with that workflow's jobs.
771841 for row in jobs.iter().filter(|row| row.status == "calling") {
842+ let prefix = row.call_prefix().unwrap_or(row.key.clone());
772843 let children: Vec<&JobRow> = jobs
773844 .iter()
774− .filter(|child| child.call().is_some_and(|call| call["role"] == "callee" && call["parent"].as_str() == Some(row.key.as_str())))
845+ .filter(|child| child.call().is_some_and(|call| call["role"] == "callee" && call["parent"].as_str() == Some(prefix.as_str())))
775846 .collect();
776847 if !children.is_empty() && children.iter().all(|child| child.status == "completed") {
777− self.finish_call(row, &children).await?;
848+ self.finish_call(&run, row, &children).await?;
778849 changed = true;
779850 }
780851 }
852+ // Calls that wait start while their matrix's `max-parallel` and
853+ // their concurrency group have room.
854+ let mut calling: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
855+ for row in jobs.iter().filter(|row| row.status == "calling") {
856+ *calling.entry(row.key.as_str()).or_default() += 1;
857+ }
858+ for row in jobs.iter().filter(|row| row.holds_call()) {
859+ let running = calling.get(row.key.as_str()).copied().unwrap_or(0);
860+ if row.max_parallel.is_some_and(|max| running >= max) || self.group_busy(row).await? {
861+ continue;
862+ }
863+ if self.start_call(row).await? {
864+ calling.insert(row.key.as_str(), running + 1);
865+ changed = true;
866+ }
867+ }
781868 if !changed {
782869 break;
783870 }
794881 let mut contexts = Self::base_contexts(run, &vars, &job.id);
795882 // A called workflow's jobs read the inputs they were called with.
796883 let call = row.call();
797− let parent = call.as_ref().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned));
884+ // Shown under the job whose call made it, by that job's name.
885+ let caller = call.as_ref().filter(|c| c["role"] == "callee").and_then(|c| {
886+ c["title"].as_str().map(str::to_owned).or_else(|| c["parent"].as_str().map(|parent| parent.replace('/', " / ")))
887+ });
798888 if let Some(call) = call.as_ref().filter(|c| c["role"] == "callee") {
799889 contexts.insert("inputs".into(), call["inputs"].clone());
800890 }
825915 Ok(true) => {}
826916 Ok(false) => return self.skip_job(row, None).await,
827917 Err(problem) => return self.fail_job(row, &format!("Its `if` does not read: {problem}")).await,
828− }
829− if let Some(uses) = &job.uses {
830− return self.call_workflow(run, job, row, uses, &scope).await;
831918 }
832−
833919 // Its matrix, which may come from a needed job's outputs.
834920 let combinations = match &job.matrix {
835921 None => vec![Map::new()],
845931 }
846932 }
847933 };
934+ // A job that calls a reusable workflow calls it once for each.
935+ if let Some(uses) = &job.uses {
936+ return self.call_workflow(run, job, row, uses, &contexts, &combinations, caller.as_deref()).await;
937+ }
848938 let total = combinations.len();
849939 let raw = job.raw.as_object().cloned().unwrap_or_default();
850940 // Whether pull requests from forks may use self-hosted runners here,
865955 status: Status::Success,
866956 hash_files: None,
867957 };
868− let base_name = job.name.clone().unwrap_or(job.id.clone());
869− // A called workflow's job is shown under the job that called it.
870− let base_name = match &parent {
871− Some(parent) => format!("{} / {base_name}", parent.replace('/', " / ")),
872− None => base_name,
873− };
874− let name = if expr::has_expression(&base_name) {
875− expr::interpolate(&base_name, &scope).unwrap_or(base_name)
876− } else if job.matrix.is_some() {
877− matrix::job_name(&base_name, combination)
878− } else {
879− base_name
880− };
958+ let name = shown_name(job, caller.as_deref(), combination, &scope);
881959 let runs_on = expr::interpolate_value(&job.runs_on, &scope).unwrap_or(Value::Null);
882960 let labels = match &runs_on {
883961 Value::String(label) => vec![label.clone()],
10661144 for (group, cancel) in groups {
10671145 let others = self
10681146 .db
1069− .prepare("SELECT * FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('queued', 'pending', 'in_progress')")
1147+ // A call that waits in the group, or calls, is in it too.
1148+ .prepare("SELECT * FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('waiting', 'queued', 'pending', 'in_progress', 'calling')")
10701149 .bind(&[run.repo_id.as_str().into(), group.as_str().into()])?
10711150 .all()
10721151 .await?
10731152 .results::<JobRow>()?;
10741153 for other in others.iter().filter(|other| !mine.contains(&other.id)) {
1075− if *cancel || other.status == "queued" {
1154+ if *cancel || matches!(other.status.as_str(), "queued" | "waiting") {
10761155 self.stop_job(other, "A newer job in the same concurrency group replaced it.").await?;
10771156 if !moved.contains(&other.run_id) {
10781157 moved.push(other.run_id.clone());
10871166 }
10881167
10891168 /// A job that calls a reusable workflow, in the repository or another
1090− /// (reach.rs): that workflow's jobs join the run under it, with the
1091− /// inputs and secrets it passes.
1092− async fn call_workflow(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, uses: &str, scope: &Scope<'_>) -> Result<()> {
1169+ /// (reach.rs): once for each combination of its matrix, as on GitHub,
1170+ /// each with the inputs and secrets it passes read with that
1171+ /// combination's `matrix`. Each call waits (`holds_call`) until its
1172+ /// matrix's `max-parallel` and its concurrency group have room, when
1173+ /// `start_call` puts the workflow's jobs in the run under it.
1174+ #[allow(clippy::too_many_arguments)]
1175+ async fn call_workflow(
1176+ &self,
1177+ run: &RunRow,
1178+ job: &workflow::Job,
1179+ row: &JobRow,
1180+ uses: &str,
1181+ contexts: &Map<String, Value>,
1182+ combinations: &[Map<String, Value>],
1183+ caller: Option<&str>,
1184+ ) -> Result<()> {
10931185 let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1;
10941186 if depth > MAX_CALL_DEPTH {
1095− return self.fail_job(row, &format!("Reusable workflows call each other more than {MAX_CALL_DEPTH} deep.")).await;
1187+ return self.fail_job(row, &call_allowed(depth, &[], uses).unwrap_err()).await;
10961188 }
10971189 let (file, source, origin) = match self.called_workflow(run, row, uses).await? {
10981190 Ok(found) => found,
10991191 Err(why) => return self.fail_job(row, &why).await,
11001192 };
1193+ let jobs = self.job_rows(&run.id).await?;
1194+ // Each call so far, by the workflow it called.
1195+ let called_paths: Vec<String> = jobs
1196+ .iter()
1197+ .filter(|other| other.call_prefix().is_some())
1198+ .filter_map(JobRow::call)
1199+ .filter_map(|call| call["path"].as_str().map(str::to_owned))
1200+ .collect();
1201+ let called_already: Vec<&str> = called_paths.iter().map(String::as_str).collect();
1202+ if let Err(why) = call_allowed(depth, &called_already, &file) {
1203+ return self.fail_job(row, &why).await;
1204+ }
11011205 let called = match workflow::parse(&source) {
11021206 Ok(called) => called,
11031207 Err(problem) => return self.fail_job(row, &format!("`{file}` does not read: {problem}")).await,
11041208 };
11051209 let Some(trigger) = called.trigger("workflow_call") else {
11061210 return self.fail_job(row, &format!("`{file}` cannot be called: it has no `on: workflow_call`.")).await;
1107− };
1108− // Inputs: what the caller passes, else the called workflow's defaults.
1109− let given = match job.raw.get("with") {
1110− Some(with) => match expr::interpolate_value(with, scope) {
1111− Ok(Value::Object(given)) => given,
1112− Ok(_) => Map::new(),
1113− Err(problem) => return self.fail_job(row, &format!("Its `with` does not read: {problem}")).await,
1114− },
1115− None => Map::new(),
11161211 };
1117− let mut inputs = Map::new();
1118− for (name, spec) in &trigger.inputs {
1119− let value = given.get(name).cloned().or_else(|| spec.get("default").cloned()).unwrap_or(Value::Null);
1120− if value.is_null() && spec.get("required").and_then(Value::as_bool) == Some(true) {
1121− return self.fail_job(row, &format!("`{file}` needs the input `{name}`.")).await;
1122− }
1123− inputs.insert(name.clone(), value);
1124− }
1125− for (name, value) in given {
1126− inputs.entry(name).or_insert(value);
1127− }
11281212 // Secrets: none but the job's token unless `secrets:` passes them,
11291213 // by name or with `inherit`; read when each job starts (job_spec).
11301214 let outer = row.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned());
1131− let secrets = crate::reach::secrets_plan(&job.raw, scope.contexts, outer);
1132− if let Some(name) = crate::reach::missing_secrets(&called.raw, &secrets).first() {
1133− return self.fail_job(row, &format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`.")).await;
1215+ let total = combinations.len();
1216+ let mut groups: Vec<(String, bool)> = Vec::new();
1217+ let mut statements = Vec::new();
1218+ for (index, combination) in combinations.iter().enumerate() {
1219+ let mut contexts = contexts.clone();
1220+ contexts.insert("matrix".into(), Value::Object(combination.clone()));
1221+ contexts.insert(
1222+ "strategy".into(),
1223+ json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }),
1224+ );
1225+ let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
1226+ let name = shown_name(job, caller, combination, &scope);
1227+ // Inputs: what `with:` passes, as the called workflow types them.
1228+ let given = match job.raw.get("with") {
1229+ Some(with) => match expr::interpolate_value(with, &scope) {
1230+ Ok(Value::Object(given)) => Ok(given),
1231+ Ok(_) => Ok(Map::new()),
1232+ Err(problem) => Err(format!("Its `with` does not read: {problem}")),
1233+ },
1234+ None => Ok(Map::new()),
1235+ };
1236+ let inputs = given.and_then(|given| crate::reach::call_inputs(&file, &trigger.inputs, &given));
1237+ let secrets = crate::reach::secrets_plan(&job.raw, &contexts, outer.clone());
1238+ let problem = match &inputs {
1239+ Err(why) => Some(why.clone()),
1240+ Ok(_) => crate::reach::missing_secrets(&called.raw, &secrets)
1241+ .first()
1242+ .map(|name| format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`."))
1243+ .or_else(|| {
1244+ crate::reach::undeclared_secrets(&called.raw, &secrets)
1245+ .first()
1246+ .map(|name| format!("Invalid secret: `{name}` is not defined in `{file}`."))
1247+ }),
1248+ };
1249+ // Its own concurrency group, which it holds while its workflow runs.
1250+ let group = job
1251+ .concurrency
1252+ .as_ref()
1253+ .and_then(|c| expr::interpolate(&c.group, &scope).ok())
1254+ .map(|group| group.trim().to_owned())
1255+ .filter(|group| !group.is_empty());
1256+ let cancel = job
1257+ .concurrency
1258+ .as_ref()
1259+ .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok())
1260+ .is_some_and(|value| expr::truthy(&value));
1261+ if problem.is_none()
1262+ && let Some(group) = &group
1263+ && !groups.iter().any(|(known, _)| known == group)
1264+ {
1265+ groups.push((group.clone(), cancel));
1266+ }
1267+ let call = json!({
1268+ "role": "caller", "path": file, "source": source, "origin": origin, "depth": depth,
1269+ "prefix": call_prefix(&row.key, job.matrix.is_some().then_some(index)), "title": name,
1270+ "inputs": inputs.unwrap_or_default(), "secrets": secrets, "fail_fast": job.fail_fast,
1271+ });
1272+ let (status, conclusion, finished) = match &problem {
1273+ Some(_) => ("completed", Some("failure"), Some(now())),
1274+ None => ("waiting", None, None),
1275+ };
1276+ let values: Vec<worker::wasm_bindgen::JsValue> = vec![
1277+ name.as_str().into(),
1278+ serde_json::to_string(combination)?.into(),
1279+ status.into(),
1280+ optional(conclusion),
1281+ optional(problem.as_deref()),
1282+ job.max_parallel.map(|max| max.max(1)).map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
1283+ optional(finished.as_deref()),
1284+ optional(group.as_deref()),
1285+ u32::from(cancel).into(),
1286+ serde_json::to_string(&call)?.into(),
1287+ ];
1288+ if index == 0 {
1289+ let mut bound = values;
1290+ bound.push(row.id.as_str().into());
1291+ statements.push(
1292+ self.db
1293+ .prepare(
1294+ "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, max_parallel = ?, finished_at = ?,
1295+ concurrency_group = ?, cancel_in_progress = ?, call = ? WHERE id = ?",
1296+ )
1297+ .bind(&bound)?,
1298+ );
1299+ } else {
1300+ let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![
1301+ new_id("job", now_ms()).into(),
1302+ row.run_id.as_str().into(),
1303+ row.repo_id.as_str().into(),
1304+ row.namespace.as_str().into(),
1305+ row.key.as_str().into(),
1306+ (index as u32).into(),
1307+ row.needs.as_str().into(),
1308+ ];
1309+ bound.extend(values);
1310+ statements.push(
1311+ self.db
1312+ .prepare(
1313+ "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, name, matrix, status, conclusion, reason,
1314+ max_parallel, finished_at, concurrency_group, cancel_in_progress, call)
1315+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1316+ )
1317+ .bind(&bound)?,
1318+ );
1319+ }
1320+ }
1321+ self.db.batch(statements).await?;
1322+ self.replace_in_groups(run, &row.key, &groups).await
1323+ }
1324+
1325+ /// Whether another job of `row`'s concurrency group runs, or calls a
1326+ /// workflow, so that `row` waits for it.
1327+ async fn group_busy(&self, row: &JobRow) -> Result<bool> {
1328+ let Some(group) = &row.concurrency_group else { return Ok(false) };
1329+ let busy = self
1330+ .db
1331+ .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?")
1332+ .bind(&[row.repo_id.as_str().into(), group.as_str().into(), row.id.as_str().into()])?
1333+ .first::<Count>(None)
1334+ .await?
1335+ .map_or(0, |count| count.n);
1336+ Ok(busy > 0)
1337+ }
1338+
1339+ /// A call that waited (`holds_call`) is made: the called workflow's
1340+ /// jobs join the run under it, named under its name and keyed under its
1341+ /// prefix. False when it no longer waits.
1342+ async fn start_call(&self, row: &JobRow) -> Result<bool> {
1343+ let Some(call) = row.call() else { return Ok(false) };
1344+ let Some(prefix) = row.call_prefix() else { return Ok(false) };
1345+ let Some(called) = call["source"].as_str().and_then(|source| workflow::parse(source).ok()) else { return Ok(false) };
1346+ let file = call["path"].as_str().unwrap_or_default();
1347+ let started = self
1348+ .db
1349+ .prepare("UPDATE jobs SET status = 'calling', reason = ?, started_at = ? WHERE id = ? AND status = 'waiting' RETURNING id")
1350+ .bind(&[format!("Calls `{file}`.").into(), now().into(), row.id.as_str().into()])?
1351+ .first::<Value>(None)
1352+ .await?;
1353+ if started.is_none() {
1354+ return Ok(false);
11341355 }
11351356 let mut statements = Vec::new();
11361357 for called_job in &called.jobs {
1137− let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{}/{n}", row.key)).collect();
1138− let call = json!({
1139− "role": "callee", "parent": row.key, "job": called_job.id, "path": file,
1140− "source": source, "inputs": inputs, "depth": depth, "origin": origin, "secrets": secrets,
1358+ let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{prefix}/{n}")).collect();
1359+ let callee = json!({
1360+ "role": "callee", "parent": prefix, "title": row.name, "job": called_job.id, "path": file,
1361+ "source": call["source"], "inputs": call["inputs"], "depth": call["depth"], "origin": call["origin"], "secrets": call["secrets"],
11411362 });
11421363 statements.push(
11431364 self.db
11471368 row.run_id.as_str().into(),
11481369 row.repo_id.as_str().into(),
11491370 row.namespace.as_str().into(),
1150− format!("{}/{}", row.key, called_job.id).into(),
1371+ format!("{prefix}/{}", called_job.id).into(),
11511372 format!("{} / {}", row.name, called_job.name.clone().unwrap_or(called_job.id.clone())).into(),
11521373 serde_json::to_string(&needs)?.into(),
1153− serde_json::to_string(&call)?.into(),
1374+ serde_json::to_string(&callee)?.into(),
11541375 ])?,
11551376 );
11561377 }
1157− statements.push(
1158− self.db
1159− .prepare("UPDATE jobs SET status = 'calling', call = ?, reason = ?, started_at = ? WHERE id = ?")
1160− .bind(&[
1161− serde_json::to_string(&json!({ "role": "caller", "path": file, "source": source }))?.into(),
1162− format!("Calls `{file}`.").into(),
1163− now().into(),
1164− row.id.as_str().into(),
1165− ])?,
1166− );
1167− self.db.batch(statements).await?;
1378+ if !statements.is_empty() {
1379+ self.db.batch(statements).await?;
1380+ }
1381+ Ok(true)
1382+ }
1383+
1384+ /// The runs with a call waiting in `group`, but `run_id`, moved along
1385+ /// now that a job of the group has finished.
1386+ async fn wake_group(&self, repo_id: &str, group: &str, run_id: &str) -> Result<()> {
1387+ #[derive(Deserialize)]
1388+ struct RunId {
1389+ run_id: String,
1390+ }
1391+ let waiting = self
1392+ .db
1393+ .prepare("SELECT DISTINCT run_id FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status = 'waiting' AND run_id != ?")
1394+ .bind(&[repo_id.into(), group.into(), run_id.into()])?
1395+ .all()
1396+ .await?
1397+ .results::<RunId>()?;
1398+ for other in waiting {
1399+ Box::pin(self.advance(&other.run_id)).await?;
1400+ }
11681401 Ok(())
11691402 }
11701403
11711404 /// A job that called a workflow, finished with its jobs: their result,
1172− /// and the outputs the workflow declares.
1173− async fn finish_call(&self, row: &JobRow, children: &[&JobRow]) -> Result<()> {
1405+ /// and the outputs the workflow declares. A failed call of a fail-fast
1406+ /// matrix stops the matrix's other calls.
1407+ async fn finish_call(&self, run: &RunRow, row: &JobRow, children: &[&JobRow]) -> Result<()> {
11741408 let call = row.call().unwrap_or_default();
11751409 let called = call["source"].as_str().and_then(|s| workflow::parse(s).ok());
11761410 let mut jobs_context = Map::new();
12061440 }
12071441 }
12081442 let conclusion = key_result(children);
1209− self.db
1210− .prepare("UPDATE jobs SET status = 'completed', conclusion = ?, outputs = ?, finished_at = ? WHERE id = ? AND status = 'calling'")
1443+ let finished = self
1444+ .db
1445+ .prepare("UPDATE jobs SET status = 'completed', conclusion = ?, outputs = ?, finished_at = ? WHERE id = ? AND status = 'calling' RETURNING id")
12111446 .bind(&[conclusion.into(), serde_json::to_string(&outputs)?.into(), now().into(), row.id.as_str().into()])?
1212− .run()
1447+ .first::<Value>(None)
12131448 .await?;
1449+ if finished.is_none() {
1450+ return Ok(());
1451+ }
1452+ if conclusion == "failure" && row.matrix.as_deref().is_some_and(|m| m != "{}") && call["fail_fast"] != json!(false) {
1453+ let siblings = self
1454+ .db
1455+ .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'")
1456+ .bind(&[row.run_id.as_str().into(), row.key.as_str().into()])?
1457+ .all()
1458+ .await?
1459+ .results::<JobRow>()?;
1460+ for sibling in siblings {
1461+ self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?;
1462+ }
1463+ }
1464+ if let Some(group) = &row.concurrency_group {
1465+ self.wake_group(&run.repo_id, group, &run.id).await?;
1466+ }
12141467 Ok(())
12151468 }
12161469
12961549 if let Some(group) = &job.concurrency_group {
12971550 let running = self
12981551 .db
1299− .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status = 'in_progress' AND id != ?")
1552+ .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?")
13001553 .bind(&[job.repo_id.as_str().into(), group.as_str().into(), job.id.as_str().into()])?
13011554 .first::<Count>(None)
13021555 .await?
14201673 // fail-fast: one failed combination stops the rest of its matrix.
14211674 if conclusion == "failure" && job.continue_on_error == 0 && job.matrix.as_deref().is_some_and(|m| m != "{}") {
14221675 let run = self.run_row(&job.run_id).await?;
1423− let fail_fast = run
1424− .as_ref()
1425− .and_then(|run| workflow::parse(&run.source).ok())
1426− .and_then(|workflow| workflow.jobs.into_iter().find(|j| j.id == job.key))
1427− .is_none_or(|j| j.fail_fast);
1676+ // As its workflow, or the workflow it is a job of, says.
1677+ let fail_fast = run.as_ref().and_then(|run| job_spec(run, &job)).is_none_or(|j| j.fail_fast);
14281678 if fail_fast {
14291679 let siblings = self
14301680 .db
14381688 }
14391689 }
14401690 }
1691+ // A call waiting in its concurrency group, in another run, may start.
1692+ if let Some(group) = &job.concurrency_group {
1693+ self.wake_group(&job.repo_id, group, &job.run_id).await?;
1694+ }
14411695 Box::pin(self.advance(&job.run_id)).await
14421696 }
14431697
14461700 /// steps and its post steps before it ends `cancelled`; one that has
14471701 /// not started is cancelled at once.
14481702 async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> {
1703+ // A job calling a workflow stops with that workflow's jobs.
1704+ if job.status == "calling"
1705+ && let Some(prefix) = job.call_prefix()
1706+ {
1707+ let under = format!("{prefix}/");
1708+ for child in self.job_rows(&job.run_id).await?.iter().filter(|child| child.key.starts_with(&under) && child.status != "completed") {
1709+ Box::pin(self.stop_job(child, reason)).await?;
1710+ }
1711+ }
14491712 if job.status == "in_progress" && job.started_at.is_some() {
14501713 self.db
14511714 .prepare("UPDATE jobs SET cancel_requested_at = COALESCE(cancel_requested_at, ?), reason = ? WHERE id = ? AND status = 'in_progress'")
18522115 ];
18532116 for key in &again {
18542117 statements.push(self.db.prepare("DELETE FROM jobs WHERE run_id = ? AND key = ? AND ordinal > 0").bind(&[run.id.as_str().into(), key.as_str().into()])?);
1855− // The jobs of a workflow it called are made again when it calls it again.
2118+ // The jobs of a workflow it called (each matrix combination's
2119+ // under `key[i]/`) are made again when it calls it again.
18562120 statements.push(
18572121 self.db
1858− .prepare("DELETE FROM jobs WHERE run_id = ? AND key LIKE ?")
1859− .bind(&[run.id.as_str().into(), format!("{key}/%").into()])?,
2122+ .prepare("DELETE FROM jobs WHERE run_id = ? AND (key LIKE ? OR key LIKE ?)")
2123+ .bind(&[run.id.as_str().into(), format!("{key}/%").into(), format!("{key}[%").into()])?,
18602124 );
18612125 statements.push(
18622126 self.db
19482212 let (default, pull_requests) = self.token_policy(&run.repo_id).await?;
19492213 let mut permissions = spec.permissions(&workflow, default);
19502214 if let Some(parent) = &job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned)) {
1951− let top = parent.split('/').next().unwrap_or(parent);
2215+ let top = top_key(parent);
19522216 if let Some(caller_job) = caller.jobs.iter().find(|j| j.id == top) {
19532217 permissions = permissions.capped_by(&caller_job.permissions(&caller, default));
19542218 }
23272591 self.finish_job(&job.id, "failure", Some(&reason), None).await?;
23282592 }
23292593 }
2594+ // Calls of reusable workflows waiting in a concurrency group, in
2595+ // case the job they waited for was cancelled in another run.
2596+ #[derive(Deserialize)]
2597+ struct RunId {
2598+ run_id: String,
2599+ }
2600+ let held = self
2601+ .db
2602+ .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'waiting' AND concurrency_group IS NOT NULL LIMIT 20")
2603+ .all()
2604+ .await?
2605+ .results::<RunId>()?;
2606+ for run in held {
2607+ self.advance(&run.run_id).await?;
2608+ }
23302609 if let Err(error) = self.sweep_runners(now_ms).await {
23312610 worker::console_error!("actions: the runners' sweep failed: {error}");
23322611 }
24792758 mod reruns {
24802759 use serde_json::{Map, Value, json};
24812760
2482− use super::{MAX_SUMMARIES, MAX_SUMMARY_BYTES, Rerun, debug_logging, rerun_keys, summary_fits, top_key};
2761+ use super::{MAX_SUMMARIES, MAX_SUMMARY_BYTES, Rerun, Scope, Status, call_allowed, call_prefix, debug_logging, rerun_keys, shown_name, summary_fits, top_key};
24832762
24842763 /// build ← test ← deploy, and lint on its own.
24852764 fn keys(which: Rerun, failed: &[&str]) -> Vec<String> {
25122791 assert_eq!(top_key("build/test"), "build");
25132792 assert_eq!(top_key("build/inner/test"), "build");
25142793 assert_eq!(top_key("lint"), "lint");
2794+ // A matrix's calls each have their own jobs, under `key[i]/`.
2795+ assert_eq!(top_key("build[1]/test"), "build");
2796+ assert_eq!(top_key("build/inner[0]/test"), "build");
2797+ assert_eq!(call_prefix("build", Some(1)), "build[1]");
2798+ assert_eq!(call_prefix("build/inner", None), "build/inner");
2799+ }
2800+
2801+ #[test]
2802+ fn a_run_connects_ten_levels_of_workflows_and_fifty_reusable_ones() {
2803+ assert!(call_allowed(1, &[], "a.yml").is_ok());
2804+ assert!(call_allowed(9, &[], "a.yml").is_ok());
2805+ let deep = call_allowed(10, &[], "a.yml").unwrap_err();
2806+ assert!(deep.contains("at most ten") && deep.contains("nine levels"), "{deep}");
2807+ let names: Vec<String> = (0..50).map(|n| format!("w{n}.yml")).collect();
2808+ let fifty: Vec<&str> = names.iter().map(String::as_str).collect();
2809+ // A workflow called again, as by each combination of a matrix, is one.
2810+ assert!(call_allowed(1, &fifty, "w3.yml").is_ok());
2811+ assert!(call_allowed(1, &fifty, "another.yml").unwrap_err().contains("at most 50 different"));
2812+ }
2813+
2814+ #[test]
2815+ fn a_matrixs_calls_are_named_by_the_caller_and_its_values() {
2816+ let workflow = super::workflow::parse(
2817+ "on: push\njobs:\n build:\n name: Build\n strategy:\n matrix: { os: [linux], node: [20] }\n uses: ./.g1t/workflows/b.yml\n plain:\n uses: ./.g1t/workflows/b.yml\n test:\n name: Test ${{ matrix.os }}\n strategy:\n matrix: { os: [linux] }\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]",
2818+ )
2819+ .unwrap();
2820+ let job = |id: &str| workflow.jobs.iter().find(|job| job.id == id).unwrap();
2821+ let combination: Map<String, Value> = serde_json::from_value(json!({ "os": "linux", "node": 20 })).unwrap();
2822+ let mut contexts = Map::new();
2823+ contexts.insert("matrix".into(), Value::Object(combination.clone()));
2824+ let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
2825+ assert_eq!(shown_name(job("build"), None, &combination, &scope), "Build (linux, 20)");
2826+ assert_eq!(shown_name(job("plain"), None, &Map::new(), &scope), "plain");
2827+ // A called workflow's job, under the call that made it.
2828+ assert_eq!(shown_name(job("plain"), Some("Build (linux, 20)"), &Map::new(), &scope), "Build (linux, 20) / plain");
2829+ assert_eq!(shown_name(job("test"), Some("Build"), &combination, &scope), "Build / Test linux");
25152830 }
25162831
25172832 #[test]
+127−0
233233 .collect()
234234 }
235235
236+/// The secrets `plan` passes by name that the called workflow does not
237+/// declare under `on.workflow_call.secrets`, which GitHub refuses.
238+pub(crate) fn undeclared_secrets(called_raw: &Value, plan: &Value) -> Vec<String> {
239+ let on = called_raw.get("on").or_else(|| called_raw.get("true")).cloned().unwrap_or(Value::Null);
240+ let declared = on.get("workflow_call").and_then(|call| call.get("secrets")).and_then(Value::as_object).cloned().unwrap_or_default();
241+ plan["map"]
242+ .as_object()
243+ .into_iter()
244+ .flatten()
245+ .map(|(name, _)| name)
246+ .filter(|name| !declared.keys().any(|known| known.eq_ignore_ascii_case(name)))
247+ .cloned()
248+ .collect()
249+}
250+
251+/// The inputs a called workflow gets, as GitHub reads them: `given` (the
252+/// caller's `with:`, its expressions read) checked against `declared`
253+/// (`on.workflow_call.inputs` of `file`). A name it does not declare is
254+/// refused, and so is a value that is not of the input's `type`: a
255+/// `boolean` takes `true` or `false`, a `number` a number (an output is
256+/// text, so `fromJSON()` it), and a `string` text, a number or a boolean
257+/// becoming its text. An input not given takes its `default`, else, unless
258+/// it is `required`, `false`, `0` or `''` by its type.
259+pub(crate) fn call_inputs(
260+ file: &str,
261+ declared: &serde_json::Map<String, Value>,
262+ given: &serde_json::Map<String, Value>,
263+) -> std::result::Result<serde_json::Map<String, Value>, String> {
264+ if let Some(name) = given.keys().find(|name| !declared.keys().any(|known| known.eq_ignore_ascii_case(name))) {
265+ return Err(format!("Invalid input: `{name}` is not defined in `{file}`."));
266+ }
267+ let mut inputs = serde_json::Map::new();
268+ for (name, spec) in declared {
269+ let kind = spec.get("type").and_then(Value::as_str).unwrap_or_default().trim().to_ascii_lowercase();
270+ let passed = given.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value).filter(|value| !value.is_null());
271+ let value = match (passed, spec.get("default").filter(|value| !value.is_null())) {
272+ (Some(value), _) => typed(&kind, value).ok_or_else(|| {
273+ let shown = match value {
274+ Value::String(text) => format!("'{text}'"),
275+ other => other.to_string(),
276+ };
277+ let hint = if value.is_string() { " An output is text: pass `${{ fromJSON(...) }}` for its value." } else { "" };
278+ format!("The input `{name}` of `{file}` is a {kind}, and `with` gives it {shown}.{hint}")
279+ })?,
280+ // A default as written, a `'true'` for a boolean taken as true.
281+ (None, Some(default)) => typed(&kind, default).or_else(|| loose(&kind, default)).unwrap_or_else(|| default.clone()),
282+ (None, None) if spec.get("required").and_then(Value::as_bool) == Some(true) => {
283+ return Err(format!("`{file}` needs the input `{name}`."));
284+ }
285+ (None, None) => match kind.as_str() {
286+ "boolean" => Value::Bool(false),
287+ "number" => json!(0),
288+ "string" => Value::String(String::new()),
289+ _ => Value::Null,
290+ },
291+ };
292+ inputs.insert(name.clone(), value);
293+ }
294+ Ok(inputs)
295+}
296+
297+/// `value` as an input of `kind`, if it is one.
298+fn typed(kind: &str, value: &Value) -> Option<Value> {
299+ match (kind, value) {
300+ ("boolean", Value::Bool(_)) | ("number", Value::Number(_)) | ("string", Value::String(_)) => Some(value.clone()),
301+ ("string", Value::Bool(_) | Value::Number(_)) => Some(Value::String(g1t_actions::expr::to_text(value))),
302+ ("boolean" | "number" | "string", _) => None,
303+ _ => Some(value.clone()),
304+ }
305+}
306+
307+/// A default written as text for a boolean or a number.
308+fn loose(kind: &str, value: &Value) -> Option<Value> {
309+ let text = value.as_str()?.trim();
310+ match kind {
311+ "boolean" if text.eq_ignore_ascii_case("true") => Some(Value::Bool(true)),
312+ "boolean" if text.eq_ignore_ascii_case("false") => Some(Value::Bool(false)),
313+ "number" => text.parse::<f64>().ok().and_then(serde_json::Number::from_f64).map(Value::Number),
314+ _ => None,
315+ }
316+}
317+
236318 /// The `secrets` a called workflow's job gets, by `plan` (`secrets_plan`),
237319 /// from `base` (the repository's secrets, as the top caller has them),
238320 /// with `github` and `vars` for the expressions. The job's token is added
408490 }
409491
410492 #[test]
493+ fn a_called_workflows_inputs_are_checked_against_their_types() {
494+ let declared: serde_json::Map<String, Value> = serde_json::from_value(json!({
495+ "flag": { "type": "boolean" },
496+ "count": { "type": "number", "default": 2 },
497+ "name": { "type": "string", "required": true },
498+ "dry": { "type": "boolean", "default": "true" },
499+ }))
500+ .unwrap();
501+ let given = |value: Value| -> serde_json::Map<String, Value> { serde_json::from_value(value).unwrap() };
502+ let file = ".g1t/workflows/build.yml";
503+ // A string input takes a number as its text; what is not given takes
504+ // its default, else false, 0 or '' by its type.
505+ let inputs = call_inputs(file, &declared, &given(json!({ "name": 18 }))).unwrap();
506+ assert_eq!(inputs["name"], json!("18"));
507+ assert_eq!(inputs["flag"], json!(false));
508+ assert_eq!(inputs["count"], json!(2));
509+ assert_eq!(inputs["dry"], json!(true));
510+ let inputs = call_inputs(file, &declared, &given(json!({ "NAME": "x", "flag": true, "count": 3.5 }))).unwrap();
511+ assert_eq!((inputs["name"].clone(), inputs["flag"].clone(), inputs["count"].clone()), (json!("x"), json!(true), json!(3.5)));
512+ // Text where a boolean or a number is declared is refused, as on GitHub.
513+ let wrong = call_inputs(file, &declared, &given(json!({ "name": "x", "flag": "true" }))).unwrap_err();
514+ assert!(wrong.contains("`flag`") && wrong.contains("boolean") && wrong.contains("fromJSON"), "{wrong}");
515+ assert!(call_inputs(file, &declared, &given(json!({ "name": "x", "count": "3" }))).unwrap_err().contains("number"));
516+ assert!(call_inputs(file, &declared, &given(json!({ "name": ["a"] }))).unwrap_err().contains("string"));
517+ // A required input left out, and one the workflow does not declare.
518+ assert_eq!(call_inputs(file, &declared, &given(json!({}))).unwrap_err(), "`.g1t/workflows/build.yml` needs the input `name`.");
519+ assert_eq!(
520+ call_inputs(file, &declared, &given(json!({ "name": "x", "extra": 1 }))).unwrap_err(),
521+ "Invalid input: `extra` is not defined in `.g1t/workflows/build.yml`."
522+ );
523+ }
524+
525+ #[test]
526+ fn secrets_a_called_workflow_does_not_declare_are_refused() {
527+ let called = g1t_actions::workflow::parse(
528+ "on:\n workflow_call:\n secrets:\n token: { required: false }\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]",
529+ )
530+ .unwrap()
531+ .raw;
532+ let plan = secrets_plan(&json!({ "secrets": { "TOKEN": "x", "other": "y" } }), &serde_json::Map::new(), None);
533+ assert_eq!(undeclared_secrets(&called, &plan), ["other"]);
534+ assert!(undeclared_secrets(&called, &secrets_plan(&json!({ "secrets": "inherit" }), &serde_json::Map::new(), None)).is_empty());
535+ }
536+
537+ #[test]
411538 fn only_workflow_files_are_called() {
412539 assert!(is_workflow_path(".g1t/workflows/build.yml"));
413540 assert!(is_workflow_path(".github/workflows/build.yaml"));
+1−1
999999 // One job of a concurrency group runs at a time.
10001000 sql.push_str(
10011001 " AND (jobs.concurrency_group IS NULL OR NOT EXISTS (SELECT 1 FROM jobs o WHERE o.repo_id = jobs.repo_id
1002− AND o.concurrency_group = jobs.concurrency_group AND o.status = 'in_progress'))",
1002+ AND o.concurrency_group = jobs.concurrency_group AND o.status IN ('in_progress', 'calling')))",
10031003 );
10041004 sql.push_str(" ORDER BY jobs.rowid LIMIT 50");
10051005 #[derive(Deserialize)]
+1−1
308308 let trusted = run.trusted != 0;
309309 match job.callee() {
310310 Some((called, spec, call)) => {
311− let parent = call["parent"].as_str().unwrap_or_default();
311+ let parent = crate::plan::top_key(call["parent"].as_str().unwrap_or_default());
312312 let caller_allows = caller.jobs.iter().find(|j| j.id == parent).is_none_or(|j| id_token_permitted(&caller.raw, &j.raw));
313313 let path = call["path"].as_str().unwrap_or(&run.path).to_owned();
314314 let environment = job.environment.clone().or_else(|| environment_name(&spec.raw));