Skip to content

Commit

A workflow is read as GitHub reads it, from GitHub's own schema for workflow files, now kept in crates/actions/schema with its licence: a key GitHub doesn't have, at the top, on a job, a step, an event or any of their mappings, is refused and the message names the job and step and lists the keys that place can have; a job without `uses` needs `runs-on`, and one with `uses` has no `runs-on` or `steps`; job and step ids follow GitHub's rule and are compared without case; an event GitHub doesn't have, a filter its event doesn't take, or `branches` beside `branches-ignore` is refused; and a YAML merge key (`<<`) is refused, as GitHub does not read them, while anchors and aliases still work. `permissions: metadata` and other names GitHub's token lacks are refused, and names GitHub has that g1t has nothing behind still read and grant nothing. A `g1t:` mapping is allowed on the workflow, a job and a step, for g1t's own settings, and `g1t: { deployment: false }` on a job does what `environment.deployment: false` does. GitHub's background steps are refused, since g1t does not run them yet, and a schedule's `timezone`, `concurrency.queue`, `cancel-timeout-minutes` and `snapshot` read with a note that they are not applied. The actions guide says how workflows are checked and what `g1t:` may hold, and the limitations page lists what is not applied.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent84c72b3Browse files
114 files+5241−230/114 viewed
+16−0
229229 macOS and Windows, on x64 and arm64.
230230 - **Status.** Not scheduled.
231231
232+### No background steps
233+
234+GitHub's background steps (`background: true` on a step, and `wait`,
235+`wait-all`, `cancel` and `parallel` steps) are refused: a workflow with one
236+does not run on g1t. A schedule's `timezone`, `concurrency.queue`,
237+`cancel-timeout-minutes` and `snapshot` are read but not applied: schedules
238+run in UTC, a concurrency group keeps one run waiting, a cancelled job stops
239+in its usual time, and no runner image is made from a job. The workflow's
240+page says so.
241+
242+- **Why.** They are newer than g1t's runner, which runs a job's steps one
243+ after another.
244+- **Instead.** Start a long-running process with `&` in a `run:` step and
245+ stop it in a later one; write a schedule's `cron` in UTC.
246+- **Status.** Not scheduled.
247+
232248 ### No `gh` command in jobs
233249
234250 The runner does not include the `gh` command, and pointing it at g1t
+40−0
9595 [actions built on the toolkit](#actions-built-on-the-toolkit).
9696 - **`on: delete`.** Deleting a branch or tag starts no workflows yet; the
9797 workflow's page says so.
98+- **Background steps.** A step with `background: true`, or a `wait`,
99+ `wait-all`, `cancel` or `parallel` step, is refused: g1t does not run
100+ them yet.
101+- **`timezone` on a schedule, `concurrency.queue`, `cancel-timeout-minutes`
102+ and `snapshot`.** The workflow runs without them, and its page says so:
103+ a schedule runs in UTC, a group keeps one run waiting, a cancelled job
104+ stops in its usual time, and no runner image is made.
98105
99106 Why each of these is missing, and what to use instead, is on
100107 [What g1t can't do yet](/about/limitations/#actions-and-runners).
101108
109+## How g1t checks a workflow
110+
111+g1t reads a workflow as GitHub does, from GitHub's own schema for workflow
112+files, so a workflow GitHub refuses is refused on g1t too, and one GitHub
113+runs reads on g1t. When a workflow can't be read, its page says what is
114+wrong, with the job and step, and no run starts from it.
115+
116+| You write | g1t says |
117+| --- | --- |
118+| A key GitHub doesn't have, at any level, such as `runs_on:` or `with:` on a `run:` step | It is unexpected, and lists the keys that place can have. |
119+| A job without `runs-on` | The job has no `runs-on`. A job that calls a reusable workflow with `uses:` has no `runs-on` or `steps`. |
120+| A job id that doesn't start with a letter or `_`, has characters other than letters, digits, `-` and `_`, or starts with `__` | It is not a job id GitHub accepts. Step ids follow the same rule. Two jobs, or two steps of a job, whose ids differ only in case are the same id. |
121+| An event GitHub doesn't have, or a filter the event doesn't take, such as `types` on `push` | GitHub has no such event, or the event doesn't take that key. `branches` and `branches-ignore` (and `tags`, `paths`) can't both be on one event. |
122+| A YAML merge key (`<<: *defaults`) | GitHub doesn't read merge keys. Anchors and aliases (`&steps` and `*steps`) work, as on GitHub, for a whole value. |
123+| A `permissions` name GitHub's token doesn't have, such as `metadata` | It is unexpected. A name GitHub has that g1t has nothing behind yet reads, and grants nothing. |
124+
125+### g1t's own settings
126+
127+A `g1t:` mapping can go at the top of a workflow, on a job and on a step.
128+GitHub refuses a workflow with `g1t:` in it, so use it only in
129+`.g1t/workflows`. It follows two rules:
130+
131+- It only adds to a workflow. It never changes what a GitHub key means.
132+- Each setting in it has a GitHub equivalent, written here, so that a
133+ workflow can be moved back to GitHub without it.
134+
135+| Setting | Where | What it does | On GitHub |
136+| --- | --- | --- | --- |
137+| `deployment: false` | A job | The job reads its `environment:` values without the run recording a deployment. | `environment: { name: ..., deployment: false }` |
138+
139+g1t keeps any other key under `g1t:` as written, and does nothing with it
140+yet.
141+
102142 ## Schedules
103143
104144 A workflow with `on: schedule` runs on the default branch's latest commit,
+21−0
1+MIT License
2+
3+Copyright GitHub
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
+12−0
1+# GitHub's workflow schema
2+
3+`workflow-v1.0.json` is GitHub's schema for workflow files, copied unchanged from
4+[actions/languageservices](https://github.com/actions/languageservices), file
5+`workflow-parser/src/workflow-v1.0.json`, at commit
6+`4043eda158e16579cc5fb1b0b07a4bce2a76f0b5` (5 August 2026). It is MIT licensed;
7+`LICENSE` is that repository's licence, copied from the same commit.
8+
9+g1t does not read it at run time. `src/syntax.rs` lists the keys each part of a
10+workflow may have, and its tests check those lists against this file, so they
11+cannot drift from GitHub's. To update it, copy the file again, write the commit
12+here, and run `cargo test -p g1t-actions`.
+2797−0
A large diff (2,797 lines) is not shown until you ask for it.
+1−0
1616 pub mod mask;
1717 pub mod matrix;
1818 pub mod permissions;
19+pub mod syntax;
1920 pub mod workflow;
+296−0
1+//! GitHub's workflow syntax: the keys each mapping in a workflow may have,
2+//! as GitHub's own schema lists them (`schema/workflow-v1.0.json`, from
3+//! actions/languageservices; the tests below check these lists against
4+//! it). [`crate::workflow::parse`] refuses any other key, as GitHub does,
5+//! except `g1t`, g1t's own settings, on the workflow, a job and a step.
6+
7+/// g1t's own key, allowed on the workflow, each job and each step.
8+pub const EXTENSION: &str = "g1t";
9+
10+/// The workflow's top level (`workflow-root-strict`).
11+pub const WORKFLOW: &[&str] = &["on", "name", "description", "run-name", "defaults", "env", "permissions", "concurrency", "jobs"];
12+
13+/// A job that runs steps on a runner (`job-factory`).
14+pub const JOB: &[&str] = &[
15+ "needs",
16+ "if",
17+ "strategy",
18+ "name",
19+ "runs-on",
20+ "timeout-minutes",
21+ "cancel-timeout-minutes",
22+ "continue-on-error",
23+ "container",
24+ "services",
25+ "env",
26+ "environment",
27+ "permissions",
28+ "concurrency",
29+ "outputs",
30+ "defaults",
31+ "steps",
32+ "snapshot",
33+];
34+
35+/// A job that calls a reusable workflow (`workflow-job`).
36+pub const CALLER_JOB: &[&str] = &["name", "uses", "with", "secrets", "needs", "if", "permissions", "concurrency", "strategy"];
37+
38+/// A step that runs a command (`run-step`).
39+pub const RUN_STEP: &[&str] =
40+ &["name", "id", "if", "timeout-minutes", "run", "continue-on-error", "env", "working-directory", "shell", "background"];
41+
42+/// A step that uses an action (`regular-step`).
43+pub const ACTION_STEP: &[&str] = &["name", "id", "if", "continue-on-error", "timeout-minutes", "uses", "with", "env", "background"];
44+
45+/// Keys of GitHub's background steps (`background`, and the `wait-step`,
46+/// `wait-all-step`, `cancel-step` and `parallel-step` steps), which g1t
47+/// does not run yet.
48+pub const BACKGROUND_STEPS: &[&str] = &["background", "wait", "wait-all", "cancel", "parallel"];
49+
50+/// `strategy`.
51+pub const STRATEGY: &[&str] = &["fail-fast", "max-parallel", "matrix"];
52+
53+/// `runs-on` as a mapping (`runs-on-mapping`).
54+pub const RUNS_ON: &[&str] = &["group", "labels"];
55+
56+/// `container` as a mapping (`container-mapping`).
57+pub const CONTAINER: &[&str] = &["image", "options", "env", "ports", "volumes", "credentials"];
58+
59+/// Each of `services` as a mapping (`service-container-mapping`).
60+pub const SERVICE: &[&str] = &["image", "options", "env", "ports", "volumes", "command", "entrypoint", "credentials"];
61+
62+/// `credentials` of a container or service (`container-registry-credentials`).
63+pub const CREDENTIALS: &[&str] = &["username", "password"];
64+
65+/// `environment` as a mapping (`job-environment-mapping`).
66+pub const ENVIRONMENT: &[&str] = &["name", "url", "deployment"];
67+
68+/// `concurrency` as a mapping (`concurrency-mapping`).
69+pub const CONCURRENCY: &[&str] = &["group", "cancel-in-progress", "queue"];
70+
71+/// `defaults` (`workflow-defaults`, `job-defaults`).
72+pub const DEFAULTS: &[&str] = &["run"];
73+
74+/// `defaults.run` (`workflow-defaults-run`, `job-defaults-run`).
75+pub const DEFAULTS_RUN: &[&str] = &["shell", "working-directory"];
76+
77+/// `permissions` as a mapping (`permissions-mapping`).
78+pub const PERMISSIONS: &[&str] = &[
79+ "actions",
80+ "artifact-metadata",
81+ "attestations",
82+ "checks",
83+ "code-quality",
84+ "contents",
85+ "copilot-requests",
86+ "deployments",
87+ "discussions",
88+ "drives",
89+ "id-token",
90+ "issues",
91+ "models",
92+ "packages",
93+ "pages",
94+ "pull-requests",
95+ "repository-projects",
96+ "security-events",
97+ "statuses",
98+ "vulnerability-alerts",
99+];
100+
101+/// Each entry of `on.schedule` (`cron-mapping`).
102+pub const CRON: &[&str] = &["cron", "timezone"];
103+
104+/// `on.workflow_dispatch` (`workflow-dispatch-mapping`).
105+pub const WORKFLOW_DISPATCH: &[&str] = &["inputs"];
106+
107+/// Each of `on.workflow_dispatch.inputs` (`workflow-dispatch-input`).
108+pub const DISPATCH_INPUT: &[&str] = &["description", "type", "required", "default", "options"];
109+
110+/// `on.workflow_call` (`workflow-call-mapping`).
111+pub const WORKFLOW_CALL: &[&str] = &["inputs", "outputs", "secrets"];
112+
113+/// Each of `on.workflow_call.inputs` (`workflow-call-input-definition`).
114+pub const CALL_INPUT: &[&str] = &["description", "type", "required", "default"];
115+
116+/// Each of `on.workflow_call.outputs` (`workflow-call-output-definition`).
117+pub const CALL_OUTPUT: &[&str] = &["description", "value"];
118+
119+/// Each of `on.workflow_call.secrets` (`workflow-call-secret-mapping-definition`).
120+pub const CALL_SECRET: &[&str] = &["description", "required"];
121+
122+/// What an event under `on` may be given.
123+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124+pub enum EventSpec {
125+ /// Nothing: `create:`.
126+ Bare,
127+ /// Nothing, or a mapping with these keys.
128+ Filters(&'static [&'static str]),
129+ /// `schedule`: a list of [`CRON`] mappings.
130+ Schedule,
131+}
132+
133+const TYPES: EventSpec = EventSpec::Filters(&["types"]);
134+const PULL_REQUEST: EventSpec = EventSpec::Filters(&["types", "branches", "branches-ignore", "paths", "paths-ignore"]);
135+
136+/// Every event GitHub has, and what each takes (`on-mapping-strict`).
137+pub const EVENTS: &[(&str, EventSpec)] = &[
138+ ("branch_protection_rule", TYPES),
139+ ("check_run", TYPES),
140+ ("check_suite", TYPES),
141+ ("create", EventSpec::Bare),
142+ ("delete", EventSpec::Bare),
143+ ("deployment", EventSpec::Bare),
144+ ("deployment_status", EventSpec::Bare),
145+ ("discussion", TYPES),
146+ ("discussion_comment", TYPES),
147+ ("fork", EventSpec::Bare),
148+ ("gollum", EventSpec::Bare),
149+ ("image_version", EventSpec::Filters(&["types", "names", "versions"])),
150+ ("issue_comment", TYPES),
151+ ("issues", TYPES),
152+ ("label", TYPES),
153+ ("merge_group", EventSpec::Filters(&["types", "branches", "branches-ignore"])),
154+ ("milestone", TYPES),
155+ ("page_build", EventSpec::Bare),
156+ ("project", TYPES),
157+ ("project_card", TYPES),
158+ ("project_column", TYPES),
159+ ("public", EventSpec::Bare),
160+ ("pull_request", PULL_REQUEST),
161+ ("pull_request_comment", TYPES),
162+ ("pull_request_review", TYPES),
163+ ("pull_request_review_comment", TYPES),
164+ ("pull_request_target", PULL_REQUEST),
165+ ("push", EventSpec::Filters(&["branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore"])),
166+ ("registry_package", TYPES),
167+ ("release", TYPES),
168+ ("repository_dispatch", TYPES),
169+ ("schedule", EventSpec::Schedule),
170+ ("status", EventSpec::Bare),
171+ ("watch", TYPES),
172+ ("workflow_call", EventSpec::Filters(WORKFLOW_CALL)),
173+ ("workflow_dispatch", EventSpec::Filters(WORKFLOW_DISPATCH)),
174+ ("workflow_run", EventSpec::Filters(&["types", "branches", "branches-ignore", "workflows"])),
175+];
176+
177+/// What `event` takes, if GitHub has it.
178+pub fn event(event: &str) -> Option<EventSpec> {
179+ EVENTS.iter().find(|(name, _)| *name == event).map(|(_, spec)| *spec)
180+}
181+
182+/// Whether `id` may name a job or a step: a letter or `_` first, then
183+/// letters, digits, `-` and `_`, under 100 characters, and not starting
184+/// with `__`, which GitHub keeps for itself.
185+pub fn valid_id(id: &str) -> bool {
186+ let mut chars = id.chars();
187+ let Some(first) = chars.next() else { return false };
188+ (first.is_ascii_alphabetic() || first == '_')
189+ && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
190+ && id.len() < 100
191+ && !id.starts_with("__")
192+}
193+
194+#[cfg(test)]
195+mod tests {
196+ use super::*;
197+ use serde_json::Value;
198+ use std::collections::BTreeSet;
199+
200+ fn schema() -> Value {
201+ serde_json::from_str(include_str!("../schema/workflow-v1.0.json")).unwrap()
202+ }
203+
204+ /// The keys of the mapping `name` in GitHub's schema.
205+ fn keys(schema: &Value, name: &str) -> BTreeSet<String> {
206+ let definition = &schema["definitions"][name];
207+ let properties = definition["mapping"]["properties"].as_object().unwrap_or_else(|| panic!("{name} is no mapping"));
208+ properties.keys().cloned().collect()
209+ }
210+
211+ fn set(list: &[&str]) -> BTreeSet<String> {
212+ list.iter().map(|key| (*key).to_owned()).collect()
213+ }
214+
215+ #[test]
216+ fn the_lists_are_githubs() {
217+ let schema = schema();
218+ for (list, name) in [
219+ (WORKFLOW, "workflow-root-strict"),
220+ (JOB, "job-factory"),
221+ (CALLER_JOB, "workflow-job"),
222+ (RUN_STEP, "run-step"),
223+ (ACTION_STEP, "regular-step"),
224+ (STRATEGY, "strategy"),
225+ (RUNS_ON, "runs-on-mapping"),
226+ (CONTAINER, "container-mapping"),
227+ (SERVICE, "service-container-mapping"),
228+ (CREDENTIALS, "container-registry-credentials"),
229+ (ENVIRONMENT, "job-environment-mapping"),
230+ (CONCURRENCY, "concurrency-mapping"),
231+ (DEFAULTS, "workflow-defaults"),
232+ (DEFAULTS, "job-defaults"),
233+ (DEFAULTS_RUN, "workflow-defaults-run"),
234+ (DEFAULTS_RUN, "job-defaults-run"),
235+ (PERMISSIONS, "permissions-mapping"),
236+ (CRON, "cron-mapping"),
237+ (WORKFLOW_DISPATCH, "workflow-dispatch-mapping"),
238+ (DISPATCH_INPUT, "workflow-dispatch-input"),
239+ (WORKFLOW_CALL, "workflow-call-mapping"),
240+ (CALL_INPUT, "workflow-call-input-definition"),
241+ (CALL_OUTPUT, "workflow-call-output-definition"),
242+ (CALL_SECRET, "workflow-call-secret-mapping-definition"),
243+ ] {
244+ assert_eq!(set(list), keys(&schema, name), "{name}");
245+ }
246+ // The background steps' own keys.
247+ let mut background = BTreeSet::from(["background".to_owned()]);
248+ for name in ["wait-step", "wait-all-step", "cancel-step", "parallel-step"] {
249+ background.extend(keys(&schema, name).into_iter().filter(|key| !["name", "id", "continue-on-error"].contains(&key.as_str())));
250+ }
251+ assert_eq!(set(BACKGROUND_STEPS), background);
252+ // Every step kind GitHub has is one of these.
253+ let kinds: Vec<&str> = schema["definitions"]["steps-item"]["one-of"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
254+ assert_eq!(kinds, ["run-step", "regular-step", "wait-step", "wait-all-step", "cancel-step", "parallel-step"]);
255+ // A job is one or the other.
256+ assert_eq!(schema["definitions"]["job"]["one-of"], serde_json::json!(["job-factory", "workflow-job"]));
257+ }
258+
259+ #[test]
260+ fn the_events_are_githubs() {
261+ let schema = schema();
262+ let definitions = &schema["definitions"];
263+ let events = definitions["on-mapping-strict"]["mapping"]["properties"].as_object().unwrap();
264+ assert_eq!(events.keys().cloned().collect::<BTreeSet<_>>(), EVENTS.iter().map(|(name, _)| (*name).to_owned()).collect());
265+ for (name, kind) in events {
266+ let kind = kind.as_str().or_else(|| kind["type"].as_str()).unwrap();
267+ let definition = &definitions[kind];
268+ let expected = if definition.get("null").is_some() {
269+ EventSpec::Bare
270+ } else if definition["sequence"]["item-type"] == "cron-mapping" {
271+ EventSpec::Schedule
272+ } else {
273+ let options: Vec<&str> = definition["one-of"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
274+ assert_eq!(options.len(), 2, "{name}");
275+ assert_eq!(options[0], "null", "{name}");
276+ let ours = match event(name) {
277+ Some(EventSpec::Filters(keys)) => set(keys),
278+ other => panic!("{name}: {other:?}"),
279+ };
280+ assert_eq!(ours, keys(&schema, options[1]), "{name}");
281+ continue;
282+ };
283+ assert_eq!(event(name), Some(expected), "{name}");
284+ }
285+ }
286+
287+ #[test]
288+ fn ids_follow_githubs_rule() {
289+ for id in ["build", "_", "_a", "a__", "build-2", "Test_all"] {
290+ assert!(valid_id(id), "{id}");
291+ }
292+ for id in ["", "my.job", "my%job", ".job", "0job", " job", "-job", "__", "__a", &"a".repeat(100)] {
293+ assert!(!valid_id(id), "{id}");
294+ }
295+ }
296+}
+371−13
77
88 use crate::filter::{Filter, Patterns};
99 use crate::permissions::{self, Permissions};
10+use crate::syntax;
1011
1112 /// Where workflows live: GitHub's `.github/workflows`, under g1t's own
1213 /// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
114115 pub condition: Option<String>,
115116 pub uses: Option<String>,
116117 pub run: Option<String>,
118+ /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
119+ pub g1t: Option<Value>,
117120 /// The whole step as written, for the sandbox.
118121 pub raw: Value,
119122 }
151154 /// Its own `concurrency`: at most one job of its group runs at a time.
152155 pub concurrency: Option<Concurrency>,
153156 pub steps: Vec<Step>,
157+ /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys.
158+ pub g1t: Option<Value>,
154159 /// The whole job as written, for the sandbox.
155160 pub raw: Value,
156161 }
177182 pub permissions: Option<Permissions>,
178183 pub jobs: Vec<Job>,
179184 pub notes: Vec<Note>,
185+ /// Its top-level `g1t` settings, as written: g1t's own, beside
186+ /// GitHub's keys.
187+ pub g1t: Option<Value>,
180188 /// The whole workflow as written.
181189 pub raw: Value,
182190 }
302310
303311 /// Reads a workflow. `Err` is what is wrong with the file, for the person
304312 /// who wrote it; what reads but runs differently is in `notes`.
313+///
314+/// It accepts what GitHub accepts and refuses what GitHub refuses: every
315+/// mapping has only the keys GitHub's schema gives it ([`syntax`]), plus
316+/// `g1t`, g1t's own settings, on the workflow, a job and a step.
305317 pub fn parse(source: &str) -> Result<Workflow, String> {
306318 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
319+ if has_merge_key(&yaml) {
320+ return Err("It uses a YAML merge key (`<<`), which GitHub does not read, so g1t does not either. Write the keys out, or give a whole value an anchor (`&name`) and repeat it with `*name`.".to_owned());
321+ }
307322 let raw = yaml_to_json(&yaml);
308323 let Value::Object(root) = &raw else {
309324 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
310325 };
311326 let mut notes = Vec::new();
312327 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
328+ check_keys(root, syntax::WORKFLOW, true, "", "the workflow")?;
313329
314− // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
315− // `true`; this reader keeps it, and accepts both.
316− let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
330+ // `on`, in any of its three shapes.
331+ let on = root.get("on").ok_or("`on` is missing: say which events start the workflow.")?;
332+ let known = |event: &str| syntax::event(event).ok_or_else(|| format!("`on`: GitHub has no `{event}` event, so neither does g1t."));
317333 let mut triggers = Vec::new();
318334 match on {
319− Value::String(event) => triggers.push(trigger(event, &Value::Null)),
335+ Value::String(event) => {
336+ known(event)?;
337+ triggers.push(trigger(event, &Value::Null));
338+ }
320339 Value::Array(events) => {
321340 for event in events {
322341 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
342+ known(event)?;
323343 triggers.push(trigger(event, &Value::Null));
324344 }
325345 }
326346 Value::Object(events) => {
327347 for (event, spec) in events {
348+ check_event(event, known(event)?, spec, &mut note)?;
328349 triggers.push(trigger(event, spec));
329350 }
330351 }
337358 note(
338359 Severity::Unsupported,
339360 None,
340− format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
361+ format!("g1t does not send GitHub's `{}` event, so that trigger never starts it.", trigger.event),
341362 );
342363 }
343364 if trigger.event == "pull_request_target" {
356377 Some(Value::Object(env)) => env.clone(),
357378 _ => Map::new(),
358379 };
380+ if let Some(defaults) = root.get("defaults") {
381+ check_defaults(defaults, "")?;
382+ }
383+ if let Some(concurrency) = root.get("concurrency") {
384+ check_concurrency(concurrency, "", None, &mut note)?;
385+ }
359386 let concurrency = concurrency_of(root.get("concurrency"));
360387 let permissions = match root.get("permissions") {
361388 None => None,
362389 Some(value) => {
390+ if let Value::Object(map) = value {
391+ check_keys(map, syntax::PERMISSIONS, false, "", "`permissions`")?;
392+ }
363393 let (permissions, unknown) = permissions::parse(value)?;
364394 permission_notes(&unknown, None, &mut note);
365395 Some(permissions)
373403 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
374404 }
375405 let mut jobs = Vec::new();
406+ let mut seen_ids: Vec<String> = Vec::new();
376407 for (id, spec) in job_specs {
408+ if !syntax::valid_id(id) {
409+ return Err(format!("`{id}` is not a job id GitHub accepts: {ID_RULE}"));
410+ }
411+ let lower = id.to_ascii_lowercase();
412+ if let Some(index) = seen_ids.iter().position(|seen| *seen == lower) {
413+ let other = job_specs.keys().nth(index).cloned().unwrap_or_default();
414+ return Err(format!("Job `{id}` is already defined as `{other}`: GitHub compares job ids without case."));
415+ }
416+ seen_ids.push(lower);
377417 let Value::Object(spec) = spec else {
378418 return Err(format!("Job `{id}` is a mapping."));
379419 };
420+ let place = format!("Job `{id}`");
380421 let uses = text(spec.get("uses"));
422+ if spec.contains_key("uses") {
423+ for key in ["runs-on", "steps"] {
424+ if spec.contains_key(key) {
425+ return Err(format!("Job `{id}` calls a reusable workflow with `uses`, so it has no `{key}`: the called workflow's jobs say that."));
426+ }
427+ }
428+ check_keys(spec, syntax::CALLER_JOB, true, &place, "a job that calls a reusable workflow")?;
429+ if uses.as_deref().is_none_or(|uses| uses.trim().is_empty()) {
430+ return Err(format!("Job `{id}`: `uses` names the reusable workflow it calls."));
431+ }
432+ match spec.get("secrets") {
433+ None | Some(Value::Object(_)) => {}
434+ Some(Value::String(inherit)) if inherit == "inherit" => {}
435+ Some(_) => return Err(format!("Job `{id}`: `secrets` is a mapping of the secrets it passes, or `inherit`.")),
436+ }
437+ } else {
438+ check_keys(spec, syntax::JOB, true, &place, "a job")?;
439+ if !spec.contains_key("runs-on") {
440+ return Err(format!("Job `{id}` has no `runs-on`: say which runner it runs on, such as `runs-on: ubuntu-latest`."));
441+ }
442+ }
443+ check_job_parts(id, spec, &mut note)?;
381444 let steps_raw = match spec.get("steps") {
382445 Some(Value::Array(steps)) => steps.clone(),
383446 None if uses.is_some() => Vec::new(),
391454 // own runners, which may be Linux, macOS or Windows.
392455 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
393456 let mut steps = Vec::new();
457+ let mut step_ids: Vec<String> = Vec::new();
394458 for (index, step) in steps_raw.iter().enumerate() {
459+ let place = format!("Job `{id}`, step {}", index + 1);
395460 let Value::Object(fields) = step else {
396− return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
461+ return Err(format!("{place}: a step is a mapping."));
397462 };
463+ if let Some(key) = syntax::BACKGROUND_STEPS.iter().find(|key| fields.contains_key(**key)) {
464+ return Err(format!(
465+ "{place}: `{key}` is one of GitHub's background steps (`background`, `wait`, `wait-all`, `cancel`, `parallel`), which g1t does not run yet."
466+ ));
467+ }
398468 let step = Step {
399469 id: text(fields.get("id")),
400470 name: text(fields.get("name")),
401471 condition: text(fields.get("if")),
402472 uses: text(fields.get("uses")),
403473 run: text(fields.get("run")),
474+ g1t: fields.get(syntax::EXTENSION).cloned(),
404475 raw: step.clone(),
405476 };
406477 match (&step.uses, &step.run) {
407− (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
408− (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
409− _ => {}
478+ (Some(_), Some(_)) => return Err(format!("{place}: a step has `uses` or `run`, not both.")),
479+ (None, None) => return Err(format!("{place}: a step needs `uses` or `run`.")),
480+ (Some(_), None) => check_keys(fields, syntax::ACTION_STEP, true, &place, "a step that uses an action")?,
481+ (None, Some(_)) => check_keys(fields, syntax::RUN_STEP, true, &place, "a step that runs a command")?,
482+ }
483+ if let Some(step_id) = &step.id {
484+ if !syntax::valid_id(step_id) {
485+ return Err(format!("{place}: `{step_id}` is not a step id GitHub accepts: {ID_RULE}"));
486+ }
487+ let lower = step_id.to_ascii_lowercase();
488+ if step_ids.contains(&lower) {
489+ return Err(format!("{place}: another step of job `{id}` has the id `{step_id}` already (GitHub compares step ids without case)."));
490+ }
491+ step_ids.push(lower);
410492 }
411493 if let Some(uses) = &step.uses
412494 && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some())
459541 let job_permissions = match spec.get("permissions") {
460542 None => None,
461543 Some(value) => {
544+ if let Value::Object(map) = value {
545+ check_keys(map, syntax::PERMISSIONS, false, &place, "`permissions`")?;
546+ }
462547 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
463548 permission_notes(&unknown, Some(id), &mut note);
464549 Some(permissions)
494579 permissions: job_permissions,
495580 concurrency: concurrency_of(spec.get("concurrency")),
496581 steps,
582+ g1t: spec.get(syntax::EXTENSION).cloned(),
497583 raw: Value::Object(spec.clone()),
498584 });
499585 }
513599 permissions,
514600 jobs,
515601 notes,
602+ g1t: root.get(syntax::EXTENSION).cloned(),
516603 raw,
517604 };
518605 if workflow.job_order().len() < workflow.jobs.len() {
521608 Ok(workflow)
522609 }
523610
611+/// Whether a job (as written) records a deployment to its environment:
612+/// unless it says `deployment: false`, in its `environment` as on GitHub,
613+/// or in its `g1t` settings.
614+pub fn records_deployment(job: &Value) -> bool {
615+ let off = |value: Option<&Value>| match value {
616+ Some(Value::Bool(flag)) => !flag,
617+ Some(Value::String(text)) => text.trim() == "false",
618+ _ => false,
619+ };
620+ !off(job.get("environment").and_then(|environment| environment.get("deployment")))
621+ && !off(job.get(syntax::EXTENSION).and_then(|settings| settings.get("deployment")))
622+}
623+
624+/// GitHub's rule for job and step ids, as said when one breaks it.
625+const ID_RULE: &str = "it starts with a letter or `_`, has only letters, digits, `-` and `_`, is under 100 characters, and does not start with `__`.";
626+
627+/// Whether a YAML merge key (`<<`) is anywhere in it.
628+fn has_merge_key(value: &serde_yaml::Value) -> bool {
629+ match value {
630+ serde_yaml::Value::Mapping(map) => map.iter().any(|(key, value)| key.as_str() == Some("<<") || has_merge_key(value)),
631+ serde_yaml::Value::Sequence(items) => items.iter().any(has_merge_key),
632+ serde_yaml::Value::Tagged(tagged) => has_merge_key(&tagged.value),
633+ _ => false,
634+ }
635+}
636+
637+/// Keys as a sentence: `a`, `b` or `c`.
638+fn listed(keys: &[&str]) -> String {
639+ let quoted: Vec<String> = keys.iter().map(|key| format!("`{key}`")).collect();
640+ match quoted.split_last() {
641+ None => String::new(),
642+ Some((last, [])) => last.clone(),
643+ Some((last, rest)) => format!("{} or {last}", rest.join(", ")),
644+ }
645+}
646+
647+/// `message`, said of `place` ("Job `build`, step 2"), or on its own for
648+/// the workflow's top level.
649+fn at(place: &str, message: &str) -> String {
650+ if place.is_empty() {
651+ let mut chars = message.chars();
652+ chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
653+ } else {
654+ format!("{place}: {message}")
655+ }
656+}
657+
658+/// Refuses any key of `map` that is not `allowed`, as GitHub does
659+/// ("Unexpected value"). With `extension`, `g1t` is allowed too, as a
660+/// mapping of anything. `what` names the mapping in the message.
661+fn check_keys(map: &Map<String, Value>, allowed: &[&str], extension: bool, place: &str, what: &str) -> Result<(), String> {
662+ for (key, value) in map {
663+ if extension && key == syntax::EXTENSION {
664+ if !value.is_object() {
665+ return Err(at(place, "`g1t` is a mapping of g1t's own settings."));
666+ }
667+ continue;
668+ }
669+ if !allowed.contains(&key.as_str()) {
670+ return Err(at(place, &format!("unexpected `{key}` in {what}; it can have {}.", listed(allowed))));
671+ }
672+ }
673+ Ok(())
674+}
675+
676+/// Refuses a mapping without `key`.
677+fn require(map: &Map<String, Value>, key: &str, place: &str, what: &str) -> Result<(), String> {
678+ if map.contains_key(key) { Ok(()) } else { Err(at(place, &format!("{what} needs `{key}`."))) }
679+}
680+
681+/// One event under `on` and what it is given.
682+fn check_event(event: &str, kind: syntax::EventSpec, spec: &Value, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
683+ let what = format!("`on.{event}`");
684+ match (kind, spec) {
685+ (syntax::EventSpec::Schedule, Value::Array(entries)) => {
686+ for entry in entries {
687+ let Value::Object(entry) = entry else { return Err("`on.schedule` is a list of `cron:` entries.".to_owned()) };
688+ check_keys(entry, syntax::CRON, false, "", "an entry of `on.schedule`")?;
689+ require(entry, "cron", "", "Each entry of `on.schedule`")?;
690+ if entry.contains_key("timezone") {
691+ note(
692+ Severity::Warning,
693+ None,
694+ "`on.schedule`: g1t reads every `cron` in UTC and does not read `timezone` yet, so the schedule runs at those times in UTC.".to_owned(),
695+ );
696+ }
697+ }
698+ Ok(())
699+ }
700+ (syntax::EventSpec::Schedule, _) => Err("`on.schedule` is a list of `cron:` entries.".to_owned()),
701+ (_, Value::Null) => Ok(()),
702+ (syntax::EventSpec::Bare, _) => Err(format!("{what} takes no settings: write `{event}:` with nothing after it.")),
703+ (syntax::EventSpec::Filters(keys), Value::Object(map)) => {
704+ check_keys(map, keys, false, "", &what)?;
705+ for only in ["branches", "tags", "paths"] {
706+ let ignore = format!("{only}-ignore");
707+ if map.contains_key(only) && map.contains_key(&ignore) {
708+ return Err(format!(
709+ "{what} has both `{only}` and `{ignore}`: GitHub takes one or the other for an event. To leave some out of `{only}`, start a pattern with `!`."
710+ ));
711+ }
712+ }
713+ let definitions = |key: &str| -> Result<Vec<(&String, &Value)>, String> {
714+ match map.get(key) {
715+ None | Some(Value::Null) => Ok(Vec::new()),
716+ Some(Value::Object(entries)) => Ok(entries.iter().collect()),
717+ Some(_) => Err(format!("`on.{event}.{key}` is a mapping of names to their definitions.")),
718+ }
719+ };
720+ if event == "workflow_dispatch" {
721+ for (name, input) in definitions("inputs")? {
722+ match input {
723+ Value::Null => {}
724+ Value::Object(input) => check_keys(input, syntax::DISPATCH_INPUT, false, "", &format!("input `{name}` of {what}"))?,
725+ _ => return Err(format!("Input `{name}` of {what} is a mapping.")),
726+ }
727+ }
728+ }
729+ if event == "workflow_call" {
730+ for (name, input) in definitions("inputs")? {
731+ let Value::Object(input) = input else { return Err(format!("Input `{name}` of {what} is a mapping with its `type`.")) };
732+ let place = format!("input `{name}` of {what}");
733+ check_keys(input, syntax::CALL_INPUT, false, "", &place)?;
734+ require(input, "type", "", &format!("Input `{name}` of {what}"))?;
735+ }
736+ for (name, output) in definitions("outputs")? {
737+ let Value::Object(output) = output else { return Err(format!("Output `{name}` of {what} is a mapping with its `value`.")) };
738+ check_keys(output, syntax::CALL_OUTPUT, false, "", &format!("output `{name}` of {what}"))?;
739+ require(output, "value", "", &format!("Output `{name}` of {what}"))?;
740+ }
741+ for (name, secret) in definitions("secrets")? {
742+ match secret {
743+ Value::Null => {}
744+ Value::Object(secret) => check_keys(secret, syntax::CALL_SECRET, false, "", &format!("secret `{name}` of {what}"))?,
745+ _ => return Err(format!("Secret `{name}` of {what} is a mapping, or nothing.")),
746+ }
747+ }
748+ }
749+ Ok(())
750+ }
751+ (syntax::EventSpec::Filters(_), _) => Err(format!("{what} is a mapping of its filters, or nothing.")),
752+ }
753+}
754+
755+/// `defaults`, at the top level or on a job.
756+fn check_defaults(value: &Value, place: &str) -> Result<(), String> {
757+ let Value::Object(defaults) = value else { return Err(at(place, "`defaults` is a mapping with `run`.")) };
758+ check_keys(defaults, syntax::DEFAULTS, false, place, "`defaults`")?;
759+ match defaults.get("run") {
760+ None | Some(Value::Null) => Ok(()),
761+ Some(Value::Object(run)) => check_keys(run, syntax::DEFAULTS_RUN, false, place, "`defaults.run`"),
762+ Some(_) => Err(at(place, "`defaults.run` is a mapping with `shell` or `working-directory`.")),
763+ }
764+}
765+
766+/// `concurrency`, at the top level or on a job (`job`).
767+fn check_concurrency(value: &Value, place: &str, job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
768+ let Value::Object(concurrency) = value else { return Ok(()) };
769+ check_keys(concurrency, syntax::CONCURRENCY, false, place, "`concurrency`")?;
770+ require(concurrency, "group", place, "`concurrency`")?;
771+ if let Some(queue) = concurrency.get("queue")
772+ && text(Some(queue)).as_deref() != Some("single")
773+ {
774+ note(
775+ Severity::Warning,
776+ job,
777+ "`concurrency.queue`: g1t keeps one run or job waiting in a group, as `queue: single` does, and does not read `queue` yet.".to_owned(),
778+ );
779+ }
780+ Ok(())
781+}
782+
783+/// A job's mappings other than its steps: each has only GitHub's keys.
784+fn check_job_parts(id: &str, spec: &Map<String, Value>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> {
785+ let place = format!("Job `{id}`");
786+ let credentials = |container: &Map<String, Value>, what: &str| -> Result<(), String> {
787+ match container.get("credentials") {
788+ Some(Value::Object(credentials)) => check_keys(credentials, syntax::CREDENTIALS, false, &place, &format!("the `credentials` of {what}")),
789+ _ => Ok(()),
790+ }
791+ };
792+ if let Some(Value::Object(runs_on)) = spec.get("runs-on") {
793+ check_keys(runs_on, syntax::RUNS_ON, false, &place, "`runs-on`")?;
794+ }
795+ if let Some(Value::Object(strategy)) = spec.get("strategy") {
796+ check_keys(strategy, syntax::STRATEGY, false, &place, "`strategy`")?;
797+ }
798+ if let Some(Value::Object(container)) = spec.get("container") {
799+ check_keys(container, syntax::CONTAINER, false, &place, "`container`")?;
800+ credentials(container, "`container`")?;
801+ }
802+ if let Some(Value::Object(services)) = spec.get("services") {
803+ for (name, service) in services {
804+ if let Value::Object(service) = service {
805+ let what = format!("service `{name}`");
806+ check_keys(service, syntax::SERVICE, false, &place, &what)?;
807+ credentials(service, &what)?;
808+ }
809+ }
810+ }
811+ if let Some(Value::Object(environment)) = spec.get("environment") {
812+ check_keys(environment, syntax::ENVIRONMENT, false, &place, "`environment`")?;
813+ require(environment, "name", &place, "`environment`")?;
814+ }
815+ if let Some(concurrency) = spec.get("concurrency") {
816+ check_concurrency(concurrency, &place, Some(id), note)?;
817+ }
818+ if let Some(defaults) = spec.get("defaults") {
819+ check_defaults(defaults, &place)?;
820+ }
821+ if spec.contains_key("snapshot") {
822+ note(Severity::Unsupported, Some(id), "`snapshot`: g1t does not make custom runner images from a job yet, so the job runs and no image is made.".to_owned());
823+ }
824+ if spec.contains_key("cancel-timeout-minutes") {
825+ note(Severity::Warning, Some(id), "`cancel-timeout-minutes`: g1t does not read it yet, so a cancelled job stops in its usual time.".to_owned());
826+ }
827+ Ok(())
828+}
829+
524830 /// `concurrency`, as a group's name or a mapping with `group` and
525831 /// `cancel-in-progress`.
526832 fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
534840 }
535841 }
536842
537−/// What to say about `permissions` names the token does not have.
843+/// What to say about `permissions` names GitHub has that g1t's token has
844+/// nothing behind.
538845 fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
539846 for name in unknown {
540− note(Severity::Warning, job, format!("`permissions.{name}`: the token has no permission called that, so it grants nothing."));
847+ note(Severity::Warning, job, format!("`permissions.{name}`: g1t's token has nothing behind that permission yet, so it grants nothing."));
541848 }
542849 }
543850
7321039 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
7331040 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
7341041 // What reads but grants nothing is said.
735− let odd = parse("on: push\npermissions: { id-token: write, wiki: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1042+ let odd = parse("on: push\npermissions: { id-token: write, artifact-metadata: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
7361043 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
737− assert!(odd.notes.iter().any(|n| n.message.contains("`permissions.wiki`")));
1044+ assert!(odd.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("`permissions.artifact-metadata`")));
1045+ // A name GitHub's token does not have is refused, as GitHub does;
1046+ // `metadata` is always `read` and cannot be written.
1047+ for name in ["wiki", "metadata"] {
1048+ let problem = parse(&format!("on: push\npermissions: {{ {name}: read }}\njobs:\n a:\n runs-on: x\n steps: [{{ run: 'true' }}]")).unwrap_err();
1049+ assert!(problem.contains(&format!("Unexpected `{name}` in `permissions`")), "{problem}");
1050+ }
7381051 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
7391052 assert!(
7401053 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
7931106 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
7941107 assert!(problem(": : :").contains("not valid YAML"));
7951108 }
1109+
1110+ #[test]
1111+ fn only_githubs_keys_and_g1ts_own() {
1112+ let problem = |yaml: &str| parse(yaml).unwrap_err();
1113+ let job = "\njobs:\n build:\n runs-on: x\n steps: [{ run: 'true' }]";
1114+ assert!(problem(&format!("on: push\nrun_name: x{job}")).starts_with("Unexpected `run_name` in the workflow; it can have `on`, "));
1115+ assert!(problem("on: push\njobs:\n build:\n runs-on: x\n step: []").starts_with("Job `build`: unexpected `step` in a job;"));
1116+ let step = problem("on: push\njobs:\n build:\n runs-on: x\n steps:\n - run: 'true'\n - uses: actions/checkout@v5\n shell: bash");
1117+ assert!(step.starts_with("Job `build`, step 2: unexpected `shell` in a step that uses an action;"), "{step}");
1118+ assert!(problem("on: push\njobs:\n build:\n steps: [{ run: 'true' }]").contains("no `runs-on`"));
1119+ assert!(problem("on: push\njobs:\n build:\n uses: ./.g1t/workflows/b.yml\n runs-on: x").contains("no `runs-on`"));
1120+ assert!(problem("on: push\njobs:\n my.job:\n runs-on: x\n steps: [{ run: 'true' }]").contains("`my.job` is not a job id"));
1121+ assert!(problem("on: pushh\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").contains("no `pushh` event"));
1122+
1123+ // `g1t` is g1t's own, at each level, and kept as written.
1124+ let workflow = parse(
1125+ "on: push
1126+g1t: { note: top }
1127+jobs:
1128+ deploy:
1129+ runs-on: x
1130+ environment: production
1131+ g1t: { deployment: false }
1132+ steps:
1133+ - run: 'true'
1134+ g1t: { anything: [1, 2] }",
1135+ )
1136+ .unwrap();
1137+ assert_eq!(workflow.g1t, Some(serde_json::json!({ "note": "top" })));
1138+ assert_eq!(workflow.jobs[0].g1t, Some(serde_json::json!({ "deployment": false })));
1139+ assert_eq!(workflow.jobs[0].steps[0].g1t, Some(serde_json::json!({ "anything": [1, 2] })));
1140+ assert!(problem(&format!("on: push\ng1t: yes{job}")).contains("`g1t` is a mapping"));
1141+ assert!(!records_deployment(&workflow.jobs[0].raw));
1142+ assert!(!records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": false } })));
1143+ assert!(records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": true } })));
1144+ assert!(records_deployment(&serde_json::json!({ "environment": "p" })));
1145+ }
1146+
1147+ #[test]
1148+ fn aliases_read_and_merge_keys_do_not() {
1149+ let aliased = parse("on: push\njobs:\n a:\n runs-on: x\n steps: &steps\n - run: 'true'\n b:\n runs-on: x\n steps: *steps").unwrap();
1150+ assert_eq!(aliased.jobs[1].steps.len(), 1);
1151+ let merged = parse("on: push\njobs:\n a: &job\n runs-on: x\n steps: [{ run: 'true' }]\n b:\n <<: *job\n name: B").unwrap_err();
1152+ assert!(merged.contains("merge key"), "{merged}");
1153+ }
7961154 }
+59−0
1+//! Workflows as people write them for GitHub (`tests/corpus/valid`), each
2+//! of which g1t reads, and workflows GitHub refuses
3+//! (`tests/corpus/invalid`), each of which g1t refuses too, with the
4+//! problem its first line names (`# error: ...`). Every file was written
5+//! for this test; the refused ones follow the cases in GitHub's own parser
6+//! tests (actions/languageservices, `workflow-parser/testdata/reader`).
7+
8+use std::path::{Path, PathBuf};
9+
10+use g1t_actions::workflow;
11+
12+fn files(kind: &str) -> Vec<(String, String)> {
13+ let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/corpus").join(kind);
14+ let mut files: Vec<(String, String)> = std::fs::read_dir(&dir)
15+ .unwrap()
16+ .map(|entry| entry.unwrap().path())
17+ .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("yml"))
18+ .map(|path: PathBuf| (name(&path), std::fs::read_to_string(&path).unwrap()))
19+ .collect();
20+ files.sort();
21+ files
22+}
23+
24+fn name(path: &Path) -> String {
25+ path.file_name().unwrap().to_string_lossy().into_owned()
26+}
27+
28+#[test]
29+fn every_github_workflow_reads() {
30+ let files = files("valid");
31+ assert!(files.len() >= 40, "{}", files.len());
32+ let mut problems = Vec::new();
33+ for (name, source) in &files {
34+ if let Err(problem) = workflow::parse(source) {
35+ problems.push(format!("{name}: {problem}"));
36+ }
37+ }
38+ assert!(problems.is_empty(), "{}", problems.join("\n"));
39+}
40+
41+#[test]
42+fn what_github_refuses_is_refused() {
43+ let files = files("invalid");
44+ assert!(files.len() >= 40, "{}", files.len());
45+ let mut problems = Vec::new();
46+ for (name, source) in &files {
47+ let expected = source
48+ .lines()
49+ .next()
50+ .and_then(|line| line.strip_prefix("# error: "))
51+ .unwrap_or_else(|| panic!("{name}: its first line is `# error: <what the problem says>`"));
52+ match workflow::parse(source) {
53+ Ok(_) => problems.push(format!("{name}: reads, and should not")),
54+ Err(problem) if !problem.contains(expected) => problems.push(format!("{name}: says {problem:?}, not {expected:?}")),
55+ Err(_) => {}
56+ }
57+ }
58+ assert!(problems.is_empty(), "{}", problems.join("\n"));
59+}
+9−0
1+# error: `background` is one of GitHub's background steps
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - id: server
8+ run: npm start
9+ background: true
+9−0
1+# error: `on.create` takes no settings
2+on:
3+ create:
4+ branches: [main]
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+10−0
1+# error: `on.push` has both `branches` and `branches-ignore`
2+on:
3+ push:
4+ branches: [main]
5+ branches-ignore: [dev]
6+jobs:
7+ build:
8+ runs-on: ubuntu-latest
9+ steps:
10+ - run: echo hi
+11−0
1+# error: Input `username` of `on.workflow_call` needs `type`
2+on:
3+ workflow_call:
4+ inputs:
5+ username:
6+ default: john-doe
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - run: echo hi
+13−0
1+# error: Unexpected `deprecationMessage` in input `app_name` of `on.workflow_call`
2+on:
3+ workflow_call:
4+ inputs:
5+ app_name:
6+ required: true
7+ type: string
8+ deprecationMessage: blah
9+jobs:
10+ build:
11+ runs-on: ubuntu-latest
12+ steps:
13+ - run: echo hi
+11−0
1+# error: Output `artifact` of `on.workflow_call` needs `value`
2+on:
3+ workflow_call:
4+ outputs:
5+ artifact:
6+ description: no value
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - run: echo hi
+11−0
1+# error: Unexpected `default` in secret `token` of `on.workflow_call`
2+on:
3+ workflow_call:
4+ secrets:
5+ token:
6+ default: abc
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - run: echo hi
+7−0
1+# error: unexpected `env` in a job that calls a reusable workflow
2+on: push
3+jobs:
4+ call:
5+ uses: ./.github/workflows/foo.yml
6+ env:
7+ A: b
+6−0
1+# error: `secrets` is a mapping of the secrets it passes, or `inherit`
2+on: push
3+jobs:
4+ call:
5+ uses: ./.github/workflows/foo.yml
6+ secrets: all
+10−0
1+# error: Unexpected `cancel_in_progress` in `concurrency`
2+on: push
3+concurrency:
4+ group: ci
5+ cancel_in_progress: true
6+jobs:
7+ build:
8+ runs-on: ubuntu-latest
9+ steps:
10+ - run: echo hi
+9−0
1+# error: `concurrency` needs `group`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ concurrency:
7+ cancel-in-progress: true
8+ steps:
9+ - run: echo hi
+10−0
1+# error: unexpected `args` in `container`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ container:
7+ image: node:22
8+ args: --rm
9+ steps:
10+ - run: echo hi
+11−0
1+# error: unexpected `token` in the `credentials` of `container`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ container:
7+ image: ghcr.io/a/b
8+ credentials:
9+ token: abc
10+ steps:
11+ - run: echo hi
+10−0
1+# error: unexpected `env` in `defaults.run`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ defaults:
7+ run:
8+ env: {}
9+ steps:
10+ - run: echo hi
+9−0
1+# error: Unexpected `shell` in `defaults`
2+on: push
3+defaults:
4+ shell: bash
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+11−0
1+# error: Unexpected `another_unknown_value` in input `name` of `on.workflow_dispatch`
2+on:
3+ workflow_dispatch:
4+ inputs:
5+ name:
6+ another_unknown_value: 123
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - run: echo hi
+9−0
1+# error: Unexpected `unknown_value` in `on.workflow_dispatch`
2+on:
3+ workflow_dispatch:
4+ unknown_value: test
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+10−0
1+# error: unexpected `reviewers` in `environment`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ environment:
7+ name: production
8+ reviewers: [a]
9+ steps:
10+ - run: echo hi
+9−0
1+# error: `environment` needs `name`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ environment:
7+ url: https://example.com
8+ steps:
9+ - run: echo hi
+8−0
1+# error: `g1t` is a mapping of g1t's own settings
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ g1t: true
7+ steps:
8+ - run: echo hi
+9−0
1+# error: unexpected `g1t` in `strategy`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ strategy:
7+ g1t: {}
8+ steps:
9+ - run: echo hi
+11−0
1+# error: Job `MY-JOB` is already defined as `my-job`
2+on: push
3+jobs:
4+ my-job:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
8+ MY-JOB:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - run: echo hi
+7−0
1+# error: `0job` is not a job id GitHub accepts
2+on: push
3+jobs:
4+ 0job:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
+7−0
1+# error: `my.job` is not a job id GitHub accepts
2+on: push
3+jobs:
4+ my.job:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
+7−0
1+# error: `__internal` is not a job id GitHub accepts
2+on: push
3+jobs:
4+ __internal:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
+13−0
1+# error: YAML merge key (`<<`)
2+on: push
3+x-env: &a
4+ A: 1
5+x-more: &b
6+ B: 2
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ env:
11+ <<: [*a, *b]
12+ steps:
13+ - run: echo hi
+10−0
1+# error: YAML merge key (`<<`)
2+on: push
3+jobs:
4+ build: &build
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
8+ build2:
9+ <<: *build
10+ name: Second
+6−0
1+# error: `on` is missing
2+jobs:
3+ build:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: echo hi
+9−0
1+# error: Job `build`: unexpected `metadata` in `permissions`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ permissions:
7+ metadata: read
8+ steps:
9+ - run: echo hi
+9−0
1+# error: Unexpected `wiki` in `permissions`
2+on: push
3+permissions:
4+ wiki: write
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+9−0
1+# error: Unexpected `branch` in `on.push`
2+on:
3+ push:
4+ branch: main
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+11−0
1+# error: Unexpected `inputs` in `on.push`
2+on:
3+ push:
4+ inputs:
5+ name:
6+ type: string
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - run: echo hi
+9−0
1+# error: Unexpected `types` in `on.push`
2+on:
3+ push:
4+ types: [created]
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+8−0
1+# error: Unexpected `true` in the workflow
2+on: push
3+true: 1
4+jobs:
5+ build:
6+ runs-on: ubuntu-latest
7+ steps:
8+ - run: echo hi
+8−0
1+# error: unexpected `label` in `runs-on`
2+on: push
3+jobs:
4+ build:
5+ runs-on:
6+ label: big
7+ steps:
8+ - run: echo hi
+6−0
1+# error: Job `build` has no `runs-on`
2+on: push
3+jobs:
4+ build:
5+ steps:
6+ - run: echo hi
+9−0
1+# error: `on.schedule` is a list of `cron:` entries
2+on:
3+ schedule:
4+ cron: '0 0 * * *'
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+9−0
1+# error: Each entry of `on.schedule` needs `cron`
2+on:
3+ schedule:
4+ - timezone: Europe/Paris
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+11−0
1+# error: unexpected `healthcheck` in service `db`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ services:
7+ db:
8+ image: postgres
9+ healthcheck: pg_isready
10+ steps:
11+ - run: echo hi
+8−0
1+# error: unexpected `shell` in a step that uses an action
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ shell: bash
+10−0
1+# error: Job `build`, step 2: another step of job `build` has the id `STEP1`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - id: step1
8+ run: echo hi
9+ - id: STEP1
10+ run: echo hi
+8−0
1+# error: `-step!` is not a step id GitHub accepts
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - id: -step!
8+ run: echo hi
+8−0
1+# error: a step has `uses` or `run`, not both
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ run: echo hi
+7−0
1+# error: `steps` is a list
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ not: a-sequence
+11−0
1+# error: Job `build`: unexpected `fail_fast` in `strategy`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ strategy:
7+ fail_fast: false
8+ matrix:
9+ a: [1]
10+ steps:
11+ - run: echo hi
+7−0
1+# error: GitHub has no `pull-request` event
2+on: [push, pull-request]
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
+9−0
1+# error: GitHub has no `commit` event
2+on:
3+ commit:
4+ branches: [main]
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+7−0
1+# error: `on`: GitHub has no `pushh` event
2+on: pushh
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
+8−0
1+# error: Job `build`: unexpected `run-on` in a job
2+on: push
3+jobs:
4+ build:
5+ run-on: ubuntu-latest
6+ runs-on: ubuntu-latest
7+ steps:
8+ - run: echo hi
+9−0
1+# error: Unexpected `job` in the workflow
2+on: push
3+job:
4+ build: {}
5+jobs:
6+ build:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - run: echo hi
+9−0
1+# error: Job `build`, step 1: unexpected `width` in a step that uses an action
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ width:
9+ fetch-depth: 0
+6−0
1+# error: calls a reusable workflow with `uses`, so it has no `runs-on`
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ uses: ./.github/workflows/foo.yml
+7−0
1+# error: so it has no `steps`
2+on: push
3+jobs:
4+ build:
5+ uses: ./.github/workflows/foo.yml
6+ steps:
7+ - run: echo hi
+7−0
1+# error: `wait` is one of GitHub's background steps
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - wait: server
+9−0
1+# error: unexpected `with` in a step that runs a command
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: echo hi
8+ with:
9+ a: b
+19−0
1+name: Anchors and aliases
2+on:
3+ push:
4+ branches: &branches [main, 'release/**']
5+ pull_request:
6+ branches: *branches
7+jobs:
8+ test:
9+ runs-on: ubuntu-latest
10+ env: &env
11+ CI: "true"
12+ LANG: C.UTF-8
13+ steps: &steps
14+ - uses: actions/checkout@v4
15+ - run: make test
16+ test-again:
17+ runs-on: ubuntu-latest
18+ env: *env
19+ steps: *steps
+20−0
1+name: Artifacts
2+on: [push]
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - run: mkdir -p out && echo hello > out/a.txt
8+ - uses: actions/upload-artifact@v4
9+ with:
10+ name: out
11+ path: out/
12+ if-no-files-found: error
13+ use:
14+ needs: build
15+ runs-on: ubuntu-latest
16+ steps:
17+ - uses: actions/download-artifact@v4
18+ with:
19+ name: out
20+ - run: cat a.txt
+15−0
1+name: Not on release branches
2+on:
3+ push:
4+ branches-ignore:
5+ - 'release/**'
6+ paths-ignore:
7+ - '**/*.md'
8+ - 'docs/**'
9+ pull_request:
10+ branches-ignore: [gh-pages]
11+jobs:
12+ build:
13+ runs-on: ubuntu-latest
14+ steps:
15+ - run: echo build
+31−0
1+name: Caching
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ - name: Cache node modules
9+ id: cache-npm
10+ uses: actions/cache@v4
11+ env:
12+ cache-name: cache-node-modules
13+ with:
14+ path: ~/.npm
15+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
16+ restore-keys: |
17+ ${{ runner.os }}-build-${{ env.cache-name }}-
18+ ${{ runner.os }}-build-
19+ ${{ runner.os }}-
20+ - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
21+ name: List the state of node modules
22+ continue-on-error: true
23+ run: npm list
24+ - uses: actions/cache/restore@v4
25+ with:
26+ path: dist
27+ key: dist-${{ github.sha }}
28+ - uses: actions/cache/save@v4
29+ with:
30+ path: dist
31+ key: dist-${{ github.sha }}
+43−0
1+name: "CodeQL"
2+on:
3+ push:
4+ branches: [ "main" ]
5+ pull_request:
6+ branches: [ "main" ]
7+ schedule:
8+ - cron: '36 1 * * 3'
9+jobs:
10+ analyze:
11+ name: Analyze (${{ matrix.language }})
12+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
13+ timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
14+ permissions:
15+ security-events: write
16+ packages: read
17+ actions: read
18+ contents: read
19+ strategy:
20+ fail-fast: false
21+ matrix:
22+ include:
23+ - language: javascript-typescript
24+ build-mode: none
25+ - language: python
26+ build-mode: none
27+ steps:
28+ - name: Checkout repository
29+ uses: actions/checkout@v4
30+ - name: Initialize CodeQL
31+ uses: github/codeql-action/init@v3
32+ with:
33+ languages: ${{ matrix.language }}
34+ build-mode: ${{ matrix.build-mode }}
35+ - if: matrix.build-mode == 'manual'
36+ shell: bash
37+ run: |
38+ echo 'Replace this with your build commands'
39+ exit 1
40+ - name: Perform CodeQL Analysis
41+ uses: github/codeql-action/analyze@v3
42+ with:
43+ category: "/language:${{matrix.language}}"
+20−0
1+name: Concurrency
2+on:
3+ pull_request:
4+ types: [opened, synchronize, reopened, ready_for_review]
5+concurrency:
6+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
7+ cancel-in-progress: true
8+jobs:
9+ lint:
10+ runs-on: ubuntu-latest
11+ concurrency: lint-${{ github.ref }}
12+ steps:
13+ - run: echo lint
14+ deploy-preview:
15+ runs-on: ubuntu-latest
16+ concurrency:
17+ group: preview-${{ github.head_ref }}
18+ cancel-in-progress: ${{ github.event.action != 'closed' }}
19+ steps:
20+ - run: echo preview
+30−0
1+name: In a container
2+on: push
3+jobs:
4+ build:
5+ runs-on: ubuntu-latest
6+ container:
7+ image: ghcr.io/example/builder:1.4
8+ credentials:
9+ username: ${{ github.actor }}
10+ password: ${{ secrets.GITHUB_TOKEN }}
11+ env:
12+ NODE_ENV: development
13+ ports:
14+ - 80
15+ volumes:
16+ - my_docker_volume:/volume_mount
17+ options: --cpus 1
18+ services:
19+ cache:
20+ image: memcached:1.6
21+ command: ["memcached", "-m", "64"]
22+ entrypoint: /usr/local/bin/docker-entrypoint.sh
23+ steps:
24+ - uses: actions/checkout@v4
25+ - run: make
26+ short-container:
27+ runs-on: ubuntu-latest
28+ container: node:22-bookworm
29+ steps:
30+ - run: node --version
+20−0
1+name: Defaults
2+on: push
3+defaults:
4+ run:
5+ shell: bash
6+ working-directory: ./app
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ defaults:
11+ run:
12+ working-directory: ./app/web
13+ steps:
14+ - uses: actions/checkout@v4
15+ - run: pwd
16+ - run: ls
17+ working-directory: ./
18+ shell: sh -e {0}
19+ - run: print("hi")
20+ shell: python
+21−0
1+name: Dependabot auto-merge
2+on: pull_request
3+permissions:
4+ contents: write
5+ pull-requests: write
6+jobs:
7+ dependabot:
8+ runs-on: ubuntu-latest
9+ if: github.event.pull_request.user.login == 'dependabot[bot]'
10+ steps:
11+ - name: Dependabot metadata
12+ id: metadata
13+ uses: dependabot/fetch-metadata@v2
14+ with:
15+ github-token: "${{ secrets.GITHUB_TOKEN }}"
16+ - name: Enable auto-merge for Dependabot PRs
17+ if: steps.metadata.outputs.update-type == 'semver-patch'
18+ run: gh pr merge --auto --merge "$PR_URL"
19+ env:
20+ PR_URL: ${{github.event.pull_request.html_url}}
21+ GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
+16−0
1+name: Described
2+description: A workflow with a description, which GitHub's schema allows.
3+on:
4+ push:
5+env:
6+ GLOBAL: one
7+ NUMBER: 2
8+ FLAG: true
9+ FROM_VAR: ${{ vars.SOMETHING }}
10+jobs:
11+ print:
12+ runs-on: ubuntu-latest
13+ env:
14+ JOB_LEVEL: ${{ env.GLOBAL }}-job
15+ steps:
16+ - run: env | sort
+14−0
1+name: Docker actions
2+on: push
3+jobs:
4+ docker:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: docker://alpine:3.20
8+ with:
9+ entrypoint: /bin/echo
10+ args: hello from alpine
11+ - uses: ./.github/actions/local-action
12+ with:
13+ who-to-greet: Mona
14+ - uses: actions/aws/ec2@main
+46−0
1+name: Docker
2+on:
3+ schedule:
4+ - cron: '27 4 * * *'
5+ push:
6+ branches: [ "main" ]
7+ tags: [ 'v*.*.*' ]
8+ pull_request:
9+ branches: [ "main" ]
10+env:
11+ REGISTRY: ghcr.io
12+ IMAGE_NAME: ${{ github.repository }}
13+jobs:
14+ build:
15+ runs-on: ubuntu-latest
16+ permissions:
17+ contents: read
18+ packages: write
19+ id-token: write
20+ steps:
21+ - name: Checkout repository
22+ uses: actions/checkout@v4
23+ - name: Set up Docker Buildx
24+ uses: docker/setup-buildx-action@v3
25+ - name: Log into registry ${{ env.REGISTRY }}
26+ if: github.event_name != 'pull_request'
27+ uses: docker/login-action@v3
28+ with:
29+ registry: ${{ env.REGISTRY }}
30+ username: ${{ github.actor }}
31+ password: ${{ secrets.GITHUB_TOKEN }}
32+ - name: Extract Docker metadata
33+ id: meta
34+ uses: docker/metadata-action@v5
35+ with:
36+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
37+ - name: Build and push Docker image
38+ id: build-and-push
39+ uses: docker/build-push-action@v6
40+ with:
41+ context: .
42+ push: ${{ github.event_name != 'pull_request' }}
43+ tags: ${{ steps.meta.outputs.tags }}
44+ labels: ${{ steps.meta.outputs.labels }}
45+ cache-from: type=gha
46+ cache-to: type=gha,mode=max
+28−0
1+name: Environments
2+on:
3+ push:
4+ branches: [main]
5+jobs:
6+ staging:
7+ runs-on: ubuntu-latest
8+ environment:
9+ name: staging
10+ url: https://staging.example.com
11+ steps:
12+ - run: ./deploy.sh staging
13+ plan:
14+ runs-on: ubuntu-latest
15+ environment:
16+ name: production
17+ deployment: false
18+ steps:
19+ - run: ./plan.sh
20+ production:
21+ needs: [staging, plan]
22+ runs-on: ubuntu-latest
23+ environment:
24+ name: production
25+ url: ${{ steps.deploy.outputs.url }}
26+ steps:
27+ - id: deploy
28+ run: echo "url=https://example.com" >> "$GITHUB_OUTPUT"
+14−0
1+name: g1t settings
2+on: push
3+g1t:
4+ anything: goes here
5+jobs:
6+ plan:
7+ runs-on: ubuntu-latest
8+ environment: production
9+ g1t:
10+ deployment: false
11+ steps:
12+ - run: echo plan
13+ g1t:
14+ note: kept as written
+25−0
1+name: Go
2+on:
3+ push:
4+ branches: [ "main" ]
5+ tags: [ "v*" ]
6+ pull_request:
7+ branches: [ "main" ]
8+jobs:
9+ build:
10+ runs-on: ubuntu-latest
11+ steps:
12+ - uses: actions/checkout@v4
13+ - name: Set up Go
14+ uses: actions/setup-go@v5
15+ with:
16+ go-version-file: go.mod
17+ - name: Build
18+ run: go build -v ./...
19+ - name: Test
20+ run: go test -race -coverprofile=coverage.out ./...
21+ - uses: actions/upload-artifact@v4
22+ with:
23+ name: coverage
24+ path: coverage.out
25+ retention-days: 5
+17−0
1+name: Conditions
2+on:
3+ pull_request:
4+ types: [labeled, unlabeled, opened, synchronize]
5+jobs:
6+ labeled:
7+ if: contains(github.event.pull_request.labels.*.name, 'deploy')
8+ runs-on: ubuntu-latest
9+ steps:
10+ - if: ${{ github.event.action == 'labeled' && github.event.label.name == 'deploy' }}
11+ run: echo just labeled
12+ - if: failure()
13+ run: echo failed
14+ - if: cancelled()
15+ run: echo cancelled
16+ - if: "!startsWith(github.head_ref, 'renovate/')"
17+ run: echo not renovate
+20−0
1+name: Comment commands
2+on:
3+ issue_comment:
4+ types: [created, edited]
5+ issues:
6+ types: [opened, labeled]
7+jobs:
8+ respond:
9+ if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/retest') }}
10+ runs-on: ubuntu-latest
11+ steps:
12+ - uses: actions/github-script@v7
13+ with:
14+ script: |
15+ await github.rest.reactions.createForIssueComment({
16+ owner: context.repo.owner,
17+ repo: context.repo.repo,
18+ comment_id: context.payload.comment.id,
19+ content: 'rocket',
20+ })
+33−0
1+name: Java CI with Gradle
2+on:
3+ push:
4+ branches: [ "main" ]
5+ pull_request:
6+ branches: [ "main" ]
7+jobs:
8+ build:
9+ runs-on: ubuntu-latest
10+ permissions:
11+ contents: read
12+ steps:
13+ - uses: actions/checkout@v4
14+ - name: Set up JDK 21
15+ uses: actions/setup-java@v4
16+ with:
17+ java-version: '21'
18+ distribution: 'temurin'
19+ - name: Setup Gradle
20+ uses: gradle/actions/setup-gradle@v4
21+ - name: Build with Gradle Wrapper
22+ run: ./gradlew build
23+ dependency-submission:
24+ runs-on: ubuntu-latest
25+ permissions:
26+ contents: write
27+ steps:
28+ - uses: actions/checkout@v4
29+ - uses: actions/setup-java@v4
30+ with:
31+ java-version: '21'
32+ distribution: 'temurin'
33+ - uses: gradle/actions/dependency-submission@v4
+27−0
1+name: Job options
2+on: push
3+jobs:
4+ first:
5+ name: The first job
6+ runs-on: ubuntu-latest
7+ timeout-minutes: 15
8+ continue-on-error: false
9+ env:
10+ LEVEL: job
11+ steps:
12+ - run: echo "$LEVEL"
13+ second:
14+ name: ${{ format('Second for {0}', github.ref_name) }}
15+ needs: first
16+ if: github.event_name == 'push'
17+ runs-on: ubuntu-latest
18+ steps:
19+ - run: echo second
20+ _private_helper:
21+ runs-on: ubuntu-latest
22+ steps:
23+ - run: echo underscore ids are allowed
24+ Mixed-Case_2:
25+ runs-on: ubuntu-latest
26+ steps:
27+ - run: echo mixed
+12−0
1+name: Labeler
2+on: [pull_request_target]
3+jobs:
4+ label:
5+ runs-on: ubuntu-latest
6+ permissions:
7+ contents: read
8+ pull-requests: write
9+ steps:
10+ - uses: actions/labeler@v5
11+ with:
12+ repo-token: "${{ secrets.GITHUB_TOKEN }}"
+40−0
1+name: Many events
2+on:
3+ branch_protection_rule:
4+ types: [created, deleted]
5+ check_run:
6+ types: [rerequested, completed]
7+ check_suite:
8+ types: [completed]
9+ create:
10+ delete:
11+ deployment:
12+ deployment_status:
13+ discussion:
14+ types: [created, edited, answered]
15+ discussion_comment:
16+ types: [created, deleted]
17+ fork:
18+ gollum:
19+ label:
20+ types: [created, deleted]
21+ milestone:
22+ types: [opened, deleted]
23+ page_build:
24+ public:
25+ pull_request_review:
26+ types: [submitted, edited]
27+ pull_request_review_comment:
28+ types: [created, deleted]
29+ registry_package:
30+ types: [published, updated]
31+ release:
32+ types: [published]
33+ status:
34+ watch:
35+ types: [started]
36+jobs:
37+ log:
38+ runs-on: ubuntu-latest
39+ steps:
40+ - run: echo "${{ github.event_name }}"
+26−0
Loading the diff…
+20−0
Loading the diff…
+12−0
Loading the diff…
+19−0
Loading the diff…
+27−0
Loading the diff…
+23−0
Loading the diff…
+10−0
Loading the diff…
+30−0
Loading the diff…
+34−0
Loading the diff…
+12−0
Loading the diff…
+16−0
Loading the diff…
+30−0
Loading the diff…
+31−0
Loading the diff…
+19−0
Loading the diff…
+9−0
Loading the diff…
+35−0
Loading the diff…
+29−0
Loading the diff…
+21−0
Loading the diff…
+30−0
Loading the diff…
+18−0
Loading the diff…
+15−0
Loading the diff…
+32−0
Loading the diff…
+6−0
Loading the diff…
+18−0
Loading the diff…
+31−0
Loading the diff…
+18−0
Loading the diff…
+32−0
Loading the diff…
+17−0
Loading the diff…
+6−10
Loading the diff…