Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.