Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

323 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), with g1t's own beside them: the `work_item` and
5//! `work_item_comment` events ([`OWN_EVENTS`]) and the `work` permission
6//! ([`OWN_PERMISSIONS`]). [`crate::workflow::parse`] refuses any other
7//! key, as GitHub does, except `g1t`, g1t's own settings, on the workflow,
8//! a job and a step.
9
10/// g1t's own key, allowed on the workflow, each job and each step.
11pub const EXTENSION: &str = "g1t";
12
13/// The workflow's top level (`workflow-root-strict`).
14pub const WORKFLOW: &[&str] = &["on", "name", "description", "run-name", "defaults", "env", "permissions", "concurrency", "jobs"];
15
16/// A job that runs steps on a runner (`job-factory`).
17pub const JOB: &[&str] = &[
18 "needs",
19 "if",
20 "strategy",
21 "name",
22 "runs-on",
23 "timeout-minutes",
24 "cancel-timeout-minutes",
25 "continue-on-error",
26 "container",
27 "services",
28 "env",
29 "environment",
30 "permissions",
31 "concurrency",
32 "outputs",
33 "defaults",
34 "steps",
35 "snapshot",
36];
37
38/// A job that calls a reusable workflow (`workflow-job`).
39pub const CALLER_JOB: &[&str] = &["name", "uses", "with", "secrets", "needs", "if", "permissions", "concurrency", "strategy"];
40
41/// A step that runs a command (`run-step`).
42pub const RUN_STEP: &[&str] =
43 &["name", "id", "if", "timeout-minutes", "run", "continue-on-error", "env", "working-directory", "shell", "background"];
44
45/// A step that uses an action (`regular-step`).
46pub const ACTION_STEP: &[&str] = &["name", "id", "if", "continue-on-error", "timeout-minutes", "uses", "with", "env", "background"];
47
48/// Keys of GitHub's background steps (`background`, and the `wait-step`,
49/// `wait-all-step`, `cancel-step` and `parallel-step` steps), which g1t
50/// does not run yet.
51pub const BACKGROUND_STEPS: &[&str] = &["background", "wait", "wait-all", "cancel", "parallel"];
52
53/// `strategy`.
54pub const STRATEGY: &[&str] = &["fail-fast", "max-parallel", "matrix"];
55
56/// `runs-on` as a mapping (`runs-on-mapping`).
57pub const RUNS_ON: &[&str] = &["group", "labels"];
58
59/// `container` as a mapping (`container-mapping`).
60pub const CONTAINER: &[&str] = &["image", "options", "env", "ports", "volumes", "credentials"];
61
62/// Each of `services` as a mapping (`service-container-mapping`).
63pub const SERVICE: &[&str] = &["image", "options", "env", "ports", "volumes", "command", "entrypoint", "credentials"];
64
65/// `credentials` of a container or service (`container-registry-credentials`).
66pub const CREDENTIALS: &[&str] = &["username", "password"];
67
68/// `environment` as a mapping (`job-environment-mapping`).
69pub const ENVIRONMENT: &[&str] = &["name", "url", "deployment"];
70
71/// `concurrency` as a mapping (`concurrency-mapping`).
72pub const CONCURRENCY: &[&str] = &["group", "cancel-in-progress", "queue"];
73
74/// `defaults` (`workflow-defaults`, `job-defaults`).
75pub const DEFAULTS: &[&str] = &["run"];
76
77/// `defaults.run` (`workflow-defaults-run`, `job-defaults-run`).
78pub const DEFAULTS_RUN: &[&str] = &["shell", "working-directory"];
79
80/// `permissions` as a mapping (`permissions-mapping`), and g1t's own.
81pub const PERMISSIONS: &[&str] = &[
82 "actions",
83 "artifact-metadata",
84 "attestations",
85 "checks",
86 "code-quality",
87 "contents",
88 "copilot-requests",
89 "deployments",
90 "discussions",
91 "drives",
92 "id-token",
93 "issues",
94 "models",
95 "packages",
96 "pages",
97 "pull-requests",
98 "repository-projects",
99 "security-events",
100 "statuses",
101 "vulnerability-alerts",
102 "work",
103];
104
105/// Permissions g1t has that GitHub does not: `work`, for work items.
106pub const OWN_PERMISSIONS: &[&str] = &["work"];
107
108/// Each entry of `on.schedule` (`cron-mapping`).
109pub const CRON: &[&str] = &["cron", "timezone"];
110
111/// `on.workflow_dispatch` (`workflow-dispatch-mapping`).
112pub const WORKFLOW_DISPATCH: &[&str] = &["inputs"];
113
114/// Each of `on.workflow_dispatch.inputs` (`workflow-dispatch-input`).
115pub const DISPATCH_INPUT: &[&str] = &["description", "type", "required", "default", "options"];
116
117/// `on.workflow_call` (`workflow-call-mapping`).
118pub const WORKFLOW_CALL: &[&str] = &["inputs", "outputs", "secrets"];
119
120/// Each of `on.workflow_call.inputs` (`workflow-call-input-definition`).
121pub const CALL_INPUT: &[&str] = &["description", "type", "required", "default"];
122
123/// Each of `on.workflow_call.outputs` (`workflow-call-output-definition`).
124pub const CALL_OUTPUT: &[&str] = &["description", "value"];
125
126/// Each of `on.workflow_call.secrets` (`workflow-call-secret-mapping-definition`).
127pub const CALL_SECRET: &[&str] = &["description", "required"];
128
129/// What an event under `on` may be given.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum EventSpec {
132 /// Nothing: `create:`.
133 Bare,
134 /// Nothing, or a mapping with these keys.
135 Filters(&'static [&'static str]),
136 /// `schedule`: a list of [`CRON`] mappings.
137 Schedule,
138}
139
140const TYPES: EventSpec = EventSpec::Filters(&["types"]);
141const PULL_REQUEST: EventSpec = EventSpec::Filters(&["types", "branches", "branches-ignore", "paths", "paths-ignore"]);
142
143/// Every event GitHub has, and what each takes (`on-mapping-strict`),
144/// and g1t's own.
145pub const EVENTS: &[(&str, EventSpec)] = &[
146 ("branch_protection_rule", TYPES),
147 ("check_run", TYPES),
148 ("check_suite", TYPES),
149 ("create", EventSpec::Bare),
150 ("delete", EventSpec::Bare),
151 ("deployment", EventSpec::Bare),
152 ("deployment_status", EventSpec::Bare),
153 ("discussion", TYPES),
154 ("discussion_comment", TYPES),
155 ("fork", EventSpec::Bare),
156 ("gollum", EventSpec::Bare),
157 ("image_version", EventSpec::Filters(&["types", "names", "versions"])),
158 ("issue_comment", TYPES),
159 ("issues", TYPES),
160 ("label", TYPES),
161 ("merge_group", EventSpec::Filters(&["types", "branches", "branches-ignore"])),
162 ("milestone", TYPES),
163 ("page_build", EventSpec::Bare),
164 ("project", TYPES),
165 ("project_card", TYPES),
166 ("project_column", TYPES),
167 ("public", EventSpec::Bare),
168 ("pull_request", PULL_REQUEST),
169 ("pull_request_comment", TYPES),
170 ("pull_request_review", TYPES),
171 ("pull_request_review_comment", TYPES),
172 ("pull_request_target", PULL_REQUEST),
173 ("push", EventSpec::Filters(&["branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore"])),
174 ("registry_package", TYPES),
175 ("release", TYPES),
176 ("repository_dispatch", TYPES),
177 ("schedule", EventSpec::Schedule),
178 ("status", EventSpec::Bare),
179 ("watch", TYPES),
180 ("workflow_call", EventSpec::Filters(WORKFLOW_CALL)),
181 ("workflow_dispatch", EventSpec::Filters(WORKFLOW_DISPATCH)),
182 ("workflow_run", EventSpec::Filters(&["types", "branches", "branches-ignore", "workflows"])),
183 ("work_item", TYPES),
184 ("work_item_comment", TYPES),
185];
186
187/// Events g1t has that GitHub does not: a work item changed, and a comment
188/// on a work item or a pull request.
189pub const OWN_EVENTS: &[&str] = &["work_item", "work_item_comment"];
190
191/// What `event` takes, if GitHub has it.
192pub fn event(event: &str) -> Option<EventSpec> {
193 EVENTS.iter().find(|(name, _)| *name == event).map(|(_, spec)| *spec)
194}
195
196/// Whether `id` may name a job or a step: a letter or `_` first, then
197/// letters, digits, `-` and `_`, under 100 characters, and not starting
198/// with `__`, which GitHub keeps for itself.
199pub fn valid_id(id: &str) -> bool {
200 let mut chars = id.chars();
201 let Some(first) = chars.next() else { return false };
202 (first.is_ascii_alphabetic() || first == '_')
203 && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
204 && id.len() < 100
205 && !id.starts_with("__")
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use serde_json::Value;
212 use std::collections::BTreeSet;
213
214 fn schema() -> Value {
215 serde_json::from_str(include_str!("../schema/workflow-v1.0.json")).unwrap()
216 }
217
218 /// The keys of the mapping `name` in GitHub's schema.
219 fn keys(schema: &Value, name: &str) -> BTreeSet<String> {
220 let definition = &schema["definitions"][name];
221 let properties = definition["mapping"]["properties"].as_object().unwrap_or_else(|| panic!("{name} is no mapping"));
222 properties.keys().cloned().collect()
223 }
224
225 fn set(list: &[&str]) -> BTreeSet<String> {
226 list.iter().map(|key| (*key).to_owned()).collect()
227 }
228
229 /// GitHub's part of a list: without g1t's own.
230 fn upstream(list: &[&str], own: &[&str]) -> BTreeSet<String> {
231 list.iter().filter(|key| !own.contains(key)).map(|key| (*key).to_owned()).collect()
232 }
233
234 #[test]
235 fn the_lists_are_githubs() {
236 let schema = schema();
237 for (list, name) in [
238 (WORKFLOW, "workflow-root-strict"),
239 (JOB, "job-factory"),
240 (CALLER_JOB, "workflow-job"),
241 (RUN_STEP, "run-step"),
242 (ACTION_STEP, "regular-step"),
243 (STRATEGY, "strategy"),
244 (RUNS_ON, "runs-on-mapping"),
245 (CONTAINER, "container-mapping"),
246 (SERVICE, "service-container-mapping"),
247 (CREDENTIALS, "container-registry-credentials"),
248 (ENVIRONMENT, "job-environment-mapping"),
249 (CONCURRENCY, "concurrency-mapping"),
250 (DEFAULTS, "workflow-defaults"),
251 (DEFAULTS, "job-defaults"),
252 (DEFAULTS_RUN, "workflow-defaults-run"),
253 (DEFAULTS_RUN, "job-defaults-run"),
254 (PERMISSIONS, "permissions-mapping"),
255 (CRON, "cron-mapping"),
256 (WORKFLOW_DISPATCH, "workflow-dispatch-mapping"),
257 (DISPATCH_INPUT, "workflow-dispatch-input"),
258 (WORKFLOW_CALL, "workflow-call-mapping"),
259 (CALL_INPUT, "workflow-call-input-definition"),
260 (CALL_OUTPUT, "workflow-call-output-definition"),
261 (CALL_SECRET, "workflow-call-secret-mapping-definition"),
262 ] {
263 assert_eq!(upstream(list, OWN_PERMISSIONS), keys(&schema, name), "{name}");
264 }
265 for own in OWN_PERMISSIONS {
266 assert!(PERMISSIONS.contains(own) && !keys(&schema, "permissions-mapping").contains(*own), "{own}");
267 }
268 // The background steps' own keys.
269 let mut background = BTreeSet::from(["background".to_owned()]);
270 for name in ["wait-step", "wait-all-step", "cancel-step", "parallel-step"] {
271 background.extend(keys(&schema, name).into_iter().filter(|key| !["name", "id", "continue-on-error"].contains(&key.as_str())));
272 }
273 assert_eq!(set(BACKGROUND_STEPS), background);
274 // Every step kind GitHub has is one of these.
275 let kinds: Vec<&str> = schema["definitions"]["steps-item"]["one-of"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
276 assert_eq!(kinds, ["run-step", "regular-step", "wait-step", "wait-all-step", "cancel-step", "parallel-step"]);
277 // A job is one or the other.
278 assert_eq!(schema["definitions"]["job"]["one-of"], serde_json::json!(["job-factory", "workflow-job"]));
279 }
280
281 #[test]
282 fn the_events_are_githubs() {
283 let schema = schema();
284 let definitions = &schema["definitions"];
285 let events = definitions["on-mapping-strict"]["mapping"]["properties"].as_object().unwrap();
286 let names: Vec<&str> = EVENTS.iter().map(|(name, _)| *name).collect();
287 assert_eq!(events.keys().cloned().collect::<BTreeSet<_>>(), upstream(&names, OWN_EVENTS));
288 for own in OWN_EVENTS {
289 assert!(!events.contains_key(*own), "{own}");
290 assert_eq!(event(own), Some(TYPES), "{own}");
291 }
292 for (name, kind) in events {
293 let kind = kind.as_str().or_else(|| kind["type"].as_str()).unwrap();
294 let definition = &definitions[kind];
295 let expected = if definition.get("null").is_some() {
296 EventSpec::Bare
297 } else if definition["sequence"]["item-type"] == "cron-mapping" {
298 EventSpec::Schedule
299 } else {
300 let options: Vec<&str> = definition["one-of"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
301 assert_eq!(options.len(), 2, "{name}");
302 assert_eq!(options[0], "null", "{name}");
303 let ours = match event(name) {
304 Some(EventSpec::Filters(keys)) => set(keys),
305 other => panic!("{name}: {other:?}"),
306 };
307 assert_eq!(ours, keys(&schema, options[1]), "{name}");
308 continue;
309 };
310 assert_eq!(event(name), Some(expected), "{name}");
311 }
312 }
313
314 #[test]
315 fn ids_follow_githubs_rule() {
316 for id in ["build", "_", "_a", "a__", "build-2", "Test_all"] {
317 assert!(valid_id(id), "{id}");
318 }
319 for id in ["", "my.job", "my%job", ".job", "0job", " job", "-job", "__", "__a", &"a".repeat(100)] {
320 assert!(!valid_id(id), "{id}");
321 }
322 }
323}