Skip to content
296 linesCodeBlameRaw
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.
8pub const EXTENSION: &str = "g1t";
9
10/// The workflow's top level (`workflow-root-strict`).
11pub 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`).
14pub 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`).
36pub const CALLER_JOB: &[&str] = &["name", "uses", "with", "secrets", "needs", "if", "permissions", "concurrency", "strategy"];
37
38/// A step that runs a command (`run-step`).
39pub 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`).
43pub 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.
48pub const BACKGROUND_STEPS: &[&str] = &["background", "wait", "wait-all", "cancel", "parallel"];
49
50/// `strategy`.
51pub const STRATEGY: &[&str] = &["fail-fast", "max-parallel", "matrix"];
52
53/// `runs-on` as a mapping (`runs-on-mapping`).
54pub const RUNS_ON: &[&str] = &["group", "labels"];
55
56/// `container` as a mapping (`container-mapping`).
57pub const CONTAINER: &[&str] = &["image", "options", "env", "ports", "volumes", "credentials"];
58
59/// Each of `services` as a mapping (`service-container-mapping`).
60pub const SERVICE: &[&str] = &["image", "options", "env", "ports", "volumes", "command", "entrypoint", "credentials"];
61
62/// `credentials` of a container or service (`container-registry-credentials`).
63pub const CREDENTIALS: &[&str] = &["username", "password"];
64
65/// `environment` as a mapping (`job-environment-mapping`).
66pub const ENVIRONMENT: &[&str] = &["name", "url", "deployment"];
67
68/// `concurrency` as a mapping (`concurrency-mapping`).
69pub const CONCURRENCY: &[&str] = &["group", "cancel-in-progress", "queue"];
70
71/// `defaults` (`workflow-defaults`, `job-defaults`).
72pub const DEFAULTS: &[&str] = &["run"];
73
74/// `defaults.run` (`workflow-defaults-run`, `job-defaults-run`).
75pub const DEFAULTS_RUN: &[&str] = &["shell", "working-directory"];
76
77/// `permissions` as a mapping (`permissions-mapping`).
78pub 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`).
102pub const CRON: &[&str] = &["cron", "timezone"];
103
104/// `on.workflow_dispatch` (`workflow-dispatch-mapping`).
105pub const WORKFLOW_DISPATCH: &[&str] = &["inputs"];
106
107/// Each of `on.workflow_dispatch.inputs` (`workflow-dispatch-input`).
108pub const DISPATCH_INPUT: &[&str] = &["description", "type", "required", "default", "options"];
109
110/// `on.workflow_call` (`workflow-call-mapping`).
111pub const WORKFLOW_CALL: &[&str] = &["inputs", "outputs", "secrets"];
112
113/// Each of `on.workflow_call.inputs` (`workflow-call-input-definition`).
114pub const CALL_INPUT: &[&str] = &["description", "type", "required", "default"];
115
116/// Each of `on.workflow_call.outputs` (`workflow-call-output-definition`).
117pub const CALL_OUTPUT: &[&str] = &["description", "value"];
118
119/// Each of `on.workflow_call.secrets` (`workflow-call-secret-mapping-definition`).
120pub const CALL_SECRET: &[&str] = &["description", "required"];
121
122/// What an event under `on` may be given.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub 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
133const TYPES: EventSpec = EventSpec::Filters(&["types"]);
134const 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`).
137pub 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.
178pub 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.
185pub 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)]
195mod 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}