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
114 files+5241−230/114 viewed
| 229 | 229 | macOS and Windows, on x64 and arm64. | |
| 230 | 230 | - **Status.** Not scheduled. | |
| 231 | 231 | ||
| 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 | + | ||
| 232 | 248 | ### No `gh` command in jobs | |
| 233 | 249 | ||
| 234 | 250 | The runner does not include the `gh` command, and pointing it at g1t |
| 95 | 95 | [actions built on the toolkit](#actions-built-on-the-toolkit). | |
| 96 | 96 | - **`on: delete`.** Deleting a branch or tag starts no workflows yet; the | |
| 97 | 97 | 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. | |
| 98 | 105 | ||
| 99 | 106 | Why each of these is missing, and what to use instead, is on | |
| 100 | 107 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| 101 | 108 | ||
| 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 | + | ||
| 102 | 142 | ## Schedules | |
| 103 | 143 | ||
| 104 | 144 | A workflow with `on: schedule` runs on the default branch's latest commit, |
| 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. |
| 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`. |
A large diff (2,797 lines) is not shown until you ask for it.
| 16 | 16 | pub mod mask; | |
| 17 | 17 | pub mod matrix; | |
| 18 | 18 | pub mod permissions; | |
| 19 | + | pub mod syntax; | |
| 19 | 20 | pub mod workflow; |
| 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 | + | } |
| 7 | 7 | ||
| 8 | 8 | use crate::filter::{Filter, Patterns}; | |
| 9 | 9 | use crate::permissions::{self, Permissions}; | |
| 10 | + | use crate::syntax; | |
| 10 | 11 | ||
| 11 | 12 | /// Where workflows live: GitHub's `.github/workflows`, under g1t's own | |
| 12 | 13 | /// folder, so moving a repository to g1t is renaming `.github` to `.g1t`. | |
| ⋯ | |||
| 114 | 115 | pub condition: Option<String>, | |
| 115 | 116 | pub uses: Option<String>, | |
| 116 | 117 | pub run: Option<String>, | |
| 118 | + | /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys. | |
| 119 | + | pub g1t: Option<Value>, | |
| 117 | 120 | /// The whole step as written, for the sandbox. | |
| 118 | 121 | pub raw: Value, | |
| 119 | 122 | } | |
| ⋯ | |||
| 151 | 154 | /// Its own `concurrency`: at most one job of its group runs at a time. | |
| 152 | 155 | pub concurrency: Option<Concurrency>, | |
| 153 | 156 | pub steps: Vec<Step>, | |
| 157 | + | /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys. | |
| 158 | + | pub g1t: Option<Value>, | |
| 154 | 159 | /// The whole job as written, for the sandbox. | |
| 155 | 160 | pub raw: Value, | |
| 156 | 161 | } | |
| ⋯ | |||
| 177 | 182 | pub permissions: Option<Permissions>, | |
| 178 | 183 | pub jobs: Vec<Job>, | |
| 179 | 184 | 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>, | |
| 180 | 188 | /// The whole workflow as written. | |
| 181 | 189 | pub raw: Value, | |
| 182 | 190 | } | |
| ⋯ | |||
| 302 | 310 | ||
| 303 | 311 | /// Reads a workflow. `Err` is what is wrong with the file, for the person | |
| 304 | 312 | /// 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. | |
| 305 | 317 | pub fn parse(source: &str) -> Result<Workflow, String> { | |
| 306 | 318 | 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 | + | } | |
| 307 | 322 | let raw = yaml_to_json(&yaml); | |
| 308 | 323 | let Value::Object(root) = &raw else { | |
| 309 | 324 | return Err("A workflow is a mapping with `on` and `jobs`.".to_owned()); | |
| 310 | 325 | }; | |
| 311 | 326 | let mut notes = Vec::new(); | |
| 312 | 327 | 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")?; | |
| 313 | 329 | ||
| 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.")); | |
| 317 | 333 | let mut triggers = Vec::new(); | |
| 318 | 334 | 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 | + | } | |
| 320 | 339 | Value::Array(events) => { | |
| 321 | 340 | for event in events { | |
| 322 | 341 | let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) }; | |
| 342 | + | known(event)?; | |
| 323 | 343 | triggers.push(trigger(event, &Value::Null)); | |
| 324 | 344 | } | |
| 325 | 345 | } | |
| 326 | 346 | Value::Object(events) => { | |
| 327 | 347 | for (event, spec) in events { | |
| 348 | + | check_event(event, known(event)?, spec, &mut note)?; | |
| 328 | 349 | triggers.push(trigger(event, spec)); | |
| 329 | 350 | } | |
| 330 | 351 | } | |
| ⋯ | |||
| 337 | 358 | note( | |
| 338 | 359 | Severity::Unsupported, | |
| 339 | 360 | 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), | |
| 341 | 362 | ); | |
| 342 | 363 | } | |
| 343 | 364 | if trigger.event == "pull_request_target" { | |
| ⋯ | |||
| 356 | 377 | Some(Value::Object(env)) => env.clone(), | |
| 357 | 378 | _ => Map::new(), | |
| 358 | 379 | }; | |
| 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 | + | } | |
| 359 | 386 | let concurrency = concurrency_of(root.get("concurrency")); | |
| 360 | 387 | let permissions = match root.get("permissions") { | |
| 361 | 388 | None => None, | |
| 362 | 389 | Some(value) => { | |
| 390 | + | if let Value::Object(map) = value { | |
| 391 | + | check_keys(map, syntax::PERMISSIONS, false, "", "`permissions`")?; | |
| 392 | + | } | |
| 363 | 393 | let (permissions, unknown) = permissions::parse(value)?; | |
| 364 | 394 | permission_notes(&unknown, None, &mut note); | |
| 365 | 395 | Some(permissions) | |
| ⋯ | |||
| 373 | 403 | return Err("`jobs` is empty: a workflow needs at least one job.".to_owned()); | |
| 374 | 404 | } | |
| 375 | 405 | let mut jobs = Vec::new(); | |
| 406 | + | let mut seen_ids: Vec<String> = Vec::new(); | |
| 376 | 407 | 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); | |
| 377 | 417 | let Value::Object(spec) = spec else { | |
| 378 | 418 | return Err(format!("Job `{id}` is a mapping.")); | |
| 379 | 419 | }; | |
| 420 | + | let place = format!("Job `{id}`"); | |
| 380 | 421 | 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)?; | |
| 381 | 444 | let steps_raw = match spec.get("steps") { | |
| 382 | 445 | Some(Value::Array(steps)) => steps.clone(), | |
| 383 | 446 | None if uses.is_some() => Vec::new(), | |
| ⋯ | |||
| 391 | 454 | // own runners, which may be Linux, macOS or Windows. | |
| 392 | 455 | let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted")); | |
| 393 | 456 | let mut steps = Vec::new(); | |
| 457 | + | let mut step_ids: Vec<String> = Vec::new(); | |
| 394 | 458 | for (index, step) in steps_raw.iter().enumerate() { | |
| 459 | + | let place = format!("Job `{id}`, step {}", index + 1); | |
| 395 | 460 | 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.")); | |
| 397 | 462 | }; | |
| 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 | + | } | |
| 398 | 468 | let step = Step { | |
| 399 | 469 | id: text(fields.get("id")), | |
| 400 | 470 | name: text(fields.get("name")), | |
| 401 | 471 | condition: text(fields.get("if")), | |
| 402 | 472 | uses: text(fields.get("uses")), | |
| 403 | 473 | run: text(fields.get("run")), | |
| 474 | + | g1t: fields.get(syntax::EXTENSION).cloned(), | |
| 404 | 475 | raw: step.clone(), | |
| 405 | 476 | }; | |
| 406 | 477 | 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); | |
| 410 | 492 | } | |
| 411 | 493 | if let Some(uses) = &step.uses | |
| 412 | 494 | && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some()) | |
| ⋯ | |||
| 459 | 541 | let job_permissions = match spec.get("permissions") { | |
| 460 | 542 | None => None, | |
| 461 | 543 | Some(value) => { | |
| 544 | + | if let Value::Object(map) = value { | |
| 545 | + | check_keys(map, syntax::PERMISSIONS, false, &place, "`permissions`")?; | |
| 546 | + | } | |
| 462 | 547 | let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?; | |
| 463 | 548 | permission_notes(&unknown, Some(id), &mut note); | |
| 464 | 549 | Some(permissions) | |
| ⋯ | |||
| 494 | 579 | permissions: job_permissions, | |
| 495 | 580 | concurrency: concurrency_of(spec.get("concurrency")), | |
| 496 | 581 | steps, | |
| 582 | + | g1t: spec.get(syntax::EXTENSION).cloned(), | |
| 497 | 583 | raw: Value::Object(spec.clone()), | |
| 498 | 584 | }); | |
| 499 | 585 | } | |
| ⋯ | |||
| 513 | 599 | permissions, | |
| 514 | 600 | jobs, | |
| 515 | 601 | notes, | |
| 602 | + | g1t: root.get(syntax::EXTENSION).cloned(), | |
| 516 | 603 | raw, | |
| 517 | 604 | }; | |
| 518 | 605 | if workflow.job_order().len() < workflow.jobs.len() { | |
| ⋯ | |||
| 521 | 608 | Ok(workflow) | |
| 522 | 609 | } | |
| 523 | 610 | ||
| 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 | + | ||
| 524 | 830 | /// `concurrency`, as a group's name or a mapping with `group` and | |
| 525 | 831 | /// `cancel-in-progress`. | |
| 526 | 832 | fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> { | |
| ⋯ | |||
| 534 | 840 | } | |
| 535 | 841 | } | |
| 536 | 842 | ||
| 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. | |
| 538 | 845 | fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) { | |
| 539 | 846 | 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.")); | |
| 541 | 848 | } | |
| 542 | 849 | } | |
| 543 | 850 | ||
| ⋯ | |||
| 732 | 1039 | assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None); | |
| 733 | 1040 | assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write); | |
| 734 | 1041 | // 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(); | |
| 736 | 1043 | 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 | + | } | |
| 738 | 1051 | assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all")); | |
| 739 | 1052 | assert!( | |
| 740 | 1053 | parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]") | |
| ⋯ | |||
| 793 | 1106 | assert!(problem("on: push\njobs: [1]").contains("`jobs`")); | |
| 794 | 1107 | assert!(problem(": : :").contains("not valid YAML")); | |
| 795 | 1108 | } | |
| 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 | + | } | |
| 796 | 1154 | } | |
| 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 | + | } |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 1 | + | # error: `on` is missing | |
| 2 | + | jobs: | |
| 3 | + | build: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: echo hi |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 1 | + | # error: Job `build` has no `runs-on` | |
| 2 | + | on: push | |
| 3 | + | jobs: | |
| 4 | + | build: | |
| 5 | + | steps: | |
| 6 | + | - run: echo hi |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 1 | + | # error: `steps` is a list | |
| 2 | + | on: push | |
| 3 | + | jobs: | |
| 4 | + | build: | |
| 5 | + | runs-on: ubuntu-latest | |
| 6 | + | steps: | |
| 7 | + | not: a-sequence |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 }} |
| 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}}" |
| 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 |
| 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 |
| 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 |
| 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}} |
| 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 |
| 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 |
| 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 |
| 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" |
| 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 |
| 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 |
| 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 |
| 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 | + | }) |
| 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 |
| 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 |
| 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 }}" |
| 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 }}" |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…