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.
| What g1t can't do yet, and an open letter to Cloudflare | 1 | //! g1t's own workflows (`.g1t/workflows/*.yml`), read by the same parser |
| 2 | //! and expressions the actions service runs them with: each reads, nothing | |
| 3 | //! in it is unsupported, and the deploy workflow's jobs start, wait and | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 4 | //! stop as docs.g1t.sh/guides/deploy-to-cloudflare/ says. |
| What g1t can't do yet, and an open letter to Cloudflare | 5 | |
| 6 | use std::path::PathBuf; | |
| 7 | ||
| 8 | use g1t_actions::expr::{self, Scope, Status}; | |
| 9 | use g1t_actions::matrix; | |
| 10 | use g1t_actions::workflow::{self, Severity, Workflow}; | |
| 11 | use serde_json::{Map, Value, json}; | |
| 12 | ||
| 13 | fn workflows_dir() -> PathBuf { | |
| 14 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.g1t/workflows") | |
| 15 | } | |
| 16 | ||
| 17 | fn read(name: &str) -> Workflow { | |
| 18 | let source = std::fs::read_to_string(workflows_dir().join(name)).unwrap(); | |
| 19 | workflow::parse(&source).unwrap_or_else(|problem| panic!("{name}: {problem}")) | |
| 20 | } | |
| 21 | ||
| 22 | #[test] | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 23 | fn every_workflow_asks_for_only_what_its_token_does() { |
| 24 | use g1t_actions::permissions::{Access, TokenDefault}; | |
| 25 | let job = |name: &str, id: &str| { | |
| 26 | let workflow = read(name); | |
| 27 | let job = workflow.jobs.iter().find(|job| job.id == id).unwrap().clone(); | |
| 28 | job.permissions(&workflow, TokenDefault::Restricted) | |
| 29 | }; | |
| 30 | // CI and Deploy only read: nothing they do writes with the token. | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 31 | // The actions release pushes with its own secret, never the job's token. |
| 32 | for (name, id) in [("ci.yml", "rust"), ("deploy.yml", "core"), ("runner-release.yml", "binaries"), ("actions-release.yml", "publish")] { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 33 | let permissions = job(name, id); |
| 34 | assert!(permissions.listed().iter().all(|(_, access)| *access <= Access::Read), "{name} {id}"); | |
| 35 | } | |
| 36 | // The runner base pushes a branch and opens a pull request. | |
| 37 | let base = job("runner-base.yml", "build"); | |
| 38 | assert_eq!(base.get("contents"), Access::Write); | |
| 39 | assert_eq!(base.get("pull-requests"), Access::Write); | |
| 40 | assert_eq!(base.get("packages"), Access::None); | |
| 41 | // The runner's image goes to g1t's registry. | |
| 42 | let image = job("runner-release.yml", "image"); | |
| 43 | assert_eq!(image.get("packages"), Access::Write); | |
| 44 | assert_eq!(image.get("contents"), Access::Read); | |
| 45 | } | |
| 46 | ||
| 47 | #[test] | |
| What g1t can't do yet, and an open letter to Cloudflare | 48 | fn every_workflow_reads_and_runs_on_g1t() { |
| 49 | let mut count = 0; | |
| 50 | for entry in std::fs::read_dir(workflows_dir()).unwrap() { | |
| 51 | let path = entry.unwrap().path(); | |
| 52 | if path.extension().and_then(|e| e.to_str()) != Some("yml") { | |
| 53 | continue; | |
| 54 | } | |
| 55 | let name = path.file_name().unwrap().to_string_lossy().to_string(); | |
| 56 | let workflow = read(&name); | |
| 57 | let unsupported: Vec<_> = workflow.notes.iter().filter(|n| n.severity != Severity::Info).collect(); | |
| 58 | assert!(unsupported.is_empty(), "{name}: {unsupported:?}"); | |
| 59 | count += 1; | |
| 60 | } | |
| 61 | assert!(count >= 1); | |
| 62 | } | |
| 63 | ||
| 64 | /// The plan job's outputs for a deploy of `stages` (each with its jobs). | |
| 65 | fn plan_outputs(migrate: bool, core: &[(&str, &str, bool)], edge: &[(&str, &str, bool)], front: &[(&str, &str, bool)]) -> Value { | |
| 66 | let matrix = |jobs: &[(&str, &str, bool)]| { | |
| 67 | let include: Vec<Value> = if jobs.is_empty() { | |
| 68 | vec![json!({ "group": "none", "units": "" })] | |
| 69 | } else { | |
| 70 | jobs.iter().map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })).collect() | |
| 71 | }; | |
| 72 | json!({ "include": include }).to_string() | |
| 73 | }; | |
| 74 | json!({ | |
| 75 | "migrate": migrate.to_string(), | |
| 76 | "migrate_units": if migrate { "events" } else { "" }, | |
| 77 | "has_core": (!core.is_empty()).to_string(), | |
| 78 | "core": matrix(core), | |
| 79 | "has_edge": (!edge.is_empty()).to_string(), | |
| 80 | "edge": matrix(edge), | |
| 81 | "has_front": (!front.is_empty()).to_string(), | |
| 82 | "front": matrix(front), | |
| 83 | }) | |
| 84 | } | |
| 85 | ||
| 86 | /// Whether `job` starts, given its needs' results, as the actions service | |
| 87 | /// decides it (services/actions/src/plan.rs `decide`, with | |
| 88 | /// `expr::job_status`): a need that was skipped is not a failure, and a | |
| 89 | /// failure anywhere before the job is. | |
| 90 | fn starts(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool) -> bool { | |
| 91 | starts_after(workflow, job, needs, outputs, inputs, cancelled, false) | |
| 92 | } | |
| 93 | ||
| 94 | /// `starts`, where `failed_before` says a job further back failed (one the | |
| 95 | /// needs were skipped for). | |
| 96 | fn starts_after(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool, failed_before: bool) -> bool { | |
| 97 | let job = workflow.jobs.iter().find(|j| j.id == job).unwrap(); | |
| 98 | let mut needs_ctx = Map::new(); | |
| 99 | let mut results = Vec::new(); | |
| 100 | for need in &job.needs { | |
| 101 | let result = needs.iter().find(|(name, _)| name == need).map(|(_, r)| *r).unwrap_or("success"); | |
| 102 | results.push(result); | |
| 103 | let outputs = if need == "plan" { outputs.clone() } else { json!({}) }; | |
| 104 | needs_ctx.insert(need.clone(), json!({ "result": result, "outputs": outputs })); | |
| 105 | } | |
| 106 | let status = expr::job_status(results, failed_before, cancelled); | |
| 107 | let mut contexts = Map::new(); | |
| 108 | contexts.insert("needs".into(), Value::Object(needs_ctx)); | |
| 109 | contexts.insert("inputs".into(), inputs); | |
| 110 | contexts.insert("github".into(), json!({ "event_name": "push", "ref": "refs/heads/main" })); | |
| 111 | let scope = Scope { contexts: &contexts, status, hash_files: None }; | |
| 112 | expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap() | |
| 113 | } | |
| 114 | ||
| 115 | #[test] | |
| 116 | fn deploy_runs_on_main_and_by_hand_one_at_a_time() { | |
| 117 | let deploy = read("deploy.yml"); | |
| 118 | let push = deploy.trigger("push").unwrap(); | |
| 119 | assert!(push.branches.allows("main")); | |
| 120 | assert!(!push.branches.allows("feature")); | |
| 121 | let dispatch = deploy.trigger("workflow_dispatch").unwrap(); | |
| 122 | for input in ["units", "all", "dry_run"] { | |
| 123 | assert!(dispatch.inputs.contains_key(input), "{input}"); | |
| 124 | } | |
| 125 | let concurrency = deploy.concurrency.as_ref().unwrap(); | |
| 126 | assert_eq!(concurrency.group, "deploy-production"); | |
| 127 | assert_eq!(concurrency.cancel_in_progress, json!(false)); | |
| Deploy ends with a smoke test: sign-in, sign-up and the waitlist still work on g1t.sh | 128 | assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front", "smoke"]); |
| What g1t can't do yet, and an open letter to Cloudflare | 129 | // The stages share their steps (a YAML alias), and read the token only |
| 130 | // where they deploy. | |
| 131 | let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len(); | |
| 132 | assert_eq!(steps("core"), steps("edge")); | |
| 133 | assert_eq!(steps("core"), steps("front")); | |
| 134 | let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap(); | |
| 135 | assert!(!source.contains("cancel-in-progress: true")); | |
| 136 | } | |
| 137 | ||
| 138 | #[test] | |
| 139 | fn deploy_stages_follow_one_another() { | |
| 140 | let deploy = read("deploy.yml"); | |
| 141 | let all = plan_outputs(true, &[("rust", "events,repos", true), ("ts", "projects", false)], &[("rust", "api", true)], &[("web", "web", false)]); | |
| 142 | let push = json!({}); | |
| 143 | ||
| 144 | // Everything succeeds: each stage runs after the last. | |
| 145 | assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false)); | |
| 146 | assert!(starts(&deploy, "core", &[("migrate", "success")], &all, push.clone(), false)); | |
| 147 | assert!(starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), false)); | |
| 148 | assert!(starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "success")], &all, push.clone(), false)); | |
| 149 | ||
| 150 | // No migrations: the migrate job is skipped, and core still runs. | |
| 151 | let none = plan_outputs(false, &[("ts", "projects", false)], &[], &[("web", "web", false)]); | |
| 152 | assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false)); | |
| 153 | assert!(starts(&deploy, "core", &[("migrate", "skipped")], &none, push.clone(), false)); | |
| 154 | // An empty stage is skipped, and the next one still runs. | |
| 155 | assert!(!starts(&deploy, "edge", &[("migrate", "skipped"), ("core", "success")], &none, push.clone(), false)); | |
| 156 | assert!(starts(&deploy, "front", &[("migrate", "skipped"), ("core", "success"), ("edge", "skipped")], &none, push.clone(), false)); | |
| 157 | ||
| 158 | // A failure stops every later stage. | |
| 159 | assert!(!starts(&deploy, "core", &[("migrate", "failure")], &all, push.clone(), false)); | |
| 160 | assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "failure")], &all, push.clone(), false)); | |
| 161 | assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "failure")], &all, push.clone(), false)); | |
| 162 | assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "failure"), ("edge", "skipped")], &all, push.clone(), false)); | |
| 163 | // A cancelled run starts nothing more. | |
| 164 | assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), true)); | |
| Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check | 165 | // Check and plan run side by side: plan waits for nothing. |
| 166 | let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap(); | |
| 167 | assert!(plan.needs.is_empty(), "{:?}", plan.needs); | |
| 168 | for id in ["migrate", "core", "edge", "front", "smoke"] { | |
| 169 | let job = deploy.jobs.iter().find(|j| j.id == id).unwrap(); | |
| 170 | assert!(job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"), "{id}: {:?}", job.needs); | |
| 171 | } | |
| 172 | // A failed check, though plan succeeded: nothing migrates or deploys, | |
| 173 | // and failure() still sees the check's failure through skipped jobs. | |
| 174 | assert!(!starts(&deploy, "migrate", &[("check", "failure"), ("plan", "success")], &all, push.clone(), false)); | |
| 175 | assert!(!starts(&deploy, "core", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped")], &all, push.clone(), false)); | |
| 176 | assert!(!starts(&deploy, "front", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")], &all, push.clone(), false)); | |
| 177 | let skipped = [("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")]; | |
| What g1t can't do yet, and an open letter to Cloudflare | 178 | assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true)); |
| Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check | 179 | assert!(!starts(&deploy, "smoke", &[("check", "failure"), ("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, push.clone(), false)); |
| 180 | // A failed plan stops everything too. | |
| 181 | assert!(!starts(&deploy, "migrate", &[("plan", "failure")], &all, push.clone(), false)); | |
| 182 | assert!(!starts(&deploy, "core", &[("plan", "failure"), ("migrate", "skipped")], &all, push.clone(), false)); | |
| What g1t can't do yet, and an open letter to Cloudflare | 183 | |
| Deploy ends with a smoke test: sign-in, sign-up and the waitlist still work on g1t.sh | 184 | // Smoke follows the last stage that ran, and not a failed one. |
| 185 | assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "success"), ("front", "success")], &all, push.clone(), false)); | |
| 186 | assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "skipped"), ("front", "success")], &none, push.clone(), false)); | |
| 187 | assert!(!starts(&deploy, "smoke", &[("core", "success"), ("edge", "failure"), ("front", "skipped")], &all, push.clone(), false)); | |
| 188 | // Nothing deployed: nothing to smoke-test. | |
| 189 | let nothing = plan_outputs(false, &[], &[], &[]); | |
| 190 | assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], ¬hing, push.clone(), false)); | |
| 191 | ||
| What g1t can't do yet, and an open letter to Cloudflare | 192 | // A dry run plans and stops. |
| 193 | let dry = json!({ "dry_run": true, "units": "", "all": false }); | |
| 194 | assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false)); | |
| Deploy ends with a smoke test: sign-in, sign-up and the waitlist still work on g1t.sh | 195 | assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry.clone(), false)); |
| 196 | assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, dry, false)); | |
| What g1t can't do yet, and an open letter to Cloudflare | 197 | } |
| 198 | ||
| 199 | #[test] | |
| 200 | fn deploy_stage_matrices_come_from_the_plan() { | |
| 201 | let deploy = read("deploy.yml"); | |
| 202 | let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]); | |
| 203 | let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap(); | |
| 204 | assert!(!core.fail_fast); | |
| 205 | assert_eq!(core.max_parallel, Some(4)); | |
| 206 | let mut contexts = Map::new(); | |
| 207 | contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } })); | |
| 208 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 209 | let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap(); | |
| 210 | let jobs = matrix::expand(&value).unwrap(); | |
| 211 | let groups: Vec<(&str, &str, bool)> = jobs | |
| 212 | .iter() | |
| 213 | .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap())) | |
| 214 | .collect(); | |
| 215 | assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]); | |
| 216 | } | |
| 217 | ||
| 218 | #[test] | |
| 219 | fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() { | |
| 220 | let deploy = read("deploy.yml"); | |
| 221 | for stage in ["core", "edge", "front"] { | |
| 222 | let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap(); | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 223 | let on = |rust: bool, image: bool| { |
| What g1t can't do yet, and an open letter to Cloudflare | 224 | let mut contexts = Map::new(); |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 225 | contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image })); |
| What g1t can't do yet, and an open letter to Cloudflare | 226 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; |
| 227 | expr::interpolate_value(&job.runs_on, &scope).unwrap() | |
| 228 | }; | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 229 | assert_eq!(on(true, false), json!("g1t-4core"), "{stage}"); |
| 230 | // The runner's image is built with the job's own Docker Engine. | |
| 231 | assert_eq!(on(false, true), json!("g1t-4core"), "{stage}"); | |
| 232 | assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}"); | |
| What g1t can't do yet, and an open letter to Cloudflare | 233 | } |
| 234 | let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap(); | |
| 235 | assert!(source.contains("target/wasm32-unknown-unknown/release")); | |
| 236 | assert!(source.contains("!target/**/incremental")); | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 237 | assert!(source.contains("target/x86_64-unknown-linux-musl/release")); |
| What g1t can't do yet, and an open letter to Cloudflare | 238 | } |
| 239 | ||
| Merge remote-tracking branch 'origin/main' into workspace-chat | 240 | /// Whether a step runs, for a job going `status`, with `matrix`. |
| 241 | fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool { | |
| 242 | let mut contexts = Map::new(); | |
| 243 | contexts.insert("matrix".into(), matrix); | |
| 244 | let scope = Scope { contexts: &contexts, status, hash_files: None }; | |
| 245 | expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap() | |
| 246 | } | |
| 247 | ||
| 248 | #[test] | |
| 249 | fn rust_builds_go_through_sccache_before_cargo_and_report_after() { | |
| 250 | let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) { | |
| 251 | let at = job.steps.iter().position(|s| s.run.as_deref() == Some(run)).unwrap_or_else(|| panic!("{}: no step runs {run}", job.id)); | |
| 252 | (at, job.steps[at].clone()) | |
| 253 | }; | |
| 254 | let deploy = read("deploy.yml"); | |
| 255 | for stage in ["core", "edge", "front"] { | |
| 256 | let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap(); | |
| 257 | let (install_at, install) = step(job, "bash scripts/sccache.sh install"); | |
| 258 | let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats"); | |
| 259 | // Before anything runs Cargo (worker-build's install, the build), | |
| 260 | // and the statistics last. | |
| 261 | let install_step = job.steps.iter().position(|s| s.name.as_deref() == Some("Install")).unwrap(); | |
| 262 | assert!(install_at < install_step, "{stage}"); | |
| 263 | assert_eq!(stats_at, job.steps.len() - 1, "{stage}"); | |
| 264 | for (rust, image, uses) in [(true, false, true), (false, true, true), (false, false, false)] { | |
| 265 | let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image }); | |
| 266 | assert_eq!(step_runs(&install, matrix.clone(), Status::Success), uses, "{stage} rust={rust} image={image}"); | |
| 267 | assert_eq!(step_runs(&stats, matrix.clone(), Status::Success), uses, "{stage}"); | |
| 268 | // A failed build still says what was cached. | |
| 269 | assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}"); | |
| 270 | } | |
| 271 | } | |
| 272 | let ci = read("ci.yml"); | |
| 273 | let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap(); | |
| 274 | let (install_at, _) = step(rust, "bash scripts/sccache.sh install"); | |
| 275 | let (tests_at, _) = step(rust, "cargo test --workspace --locked --quiet"); | |
| 276 | let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats"); | |
| 277 | assert!(install_at < tests_at && tests_at < stats_at); | |
| 278 | assert!(step_runs(&stats, json!({}), Status::Failure)); | |
| 279 | // main's runs keep the caches pull requests restore from: only Rust. | |
| 280 | assert!(ci.trigger("push").unwrap().branches.allows("main")); | |
| 281 | assert!(ci.trigger("pull_request").is_some()); | |
| 282 | for (id, on_push) in [("rust", true), ("typescript", false), ("build", false)] { | |
| 283 | let job = ci.jobs.iter().find(|j| j.id == id).unwrap(); | |
| 284 | for (event, expected) in [("push", on_push), ("pull_request", true)] { | |
| 285 | let mut contexts = Map::new(); | |
| 286 | contexts.insert("github".into(), json!({ "event_name": event, "ref": "refs/heads/main" })); | |
| 287 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 288 | assert_eq!(expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(), expected, "{id} on {event}"); | |
| 289 | } | |
| 290 | } | |
| 291 | // The download is pinned by version and checksum. | |
| 292 | let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap(); | |
| 293 | assert!(script.contains("VERSION=0.18.0")); | |
| 294 | assert!(script.lines().any(|line| line.strip_prefix("SHA256=").is_some_and(|sum| sum.len() == 64))); | |
| 295 | assert!(script.contains("sha256sum -c")); | |
| 296 | assert!(script.contains("RUSTC_WRAPPER=")); | |
| 297 | assert!(script.contains("GITHUB_STEP_SUMMARY")); | |
| 298 | } | |
| 299 | ||
| What g1t can't do yet, and an open letter to Cloudflare | 300 | #[test] |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 301 | fn g1ts_own_actions_are_published_when_they_change() { |
| 302 | let release = read("actions-release.yml"); | |
| 303 | let push = release.trigger("push").unwrap(); | |
| 304 | assert!(push.branches.allows("main")); | |
| 305 | assert!(!push.branches.allows("feature")); | |
| 306 | assert!(release.trigger("workflow_dispatch").is_some()); | |
| 307 | let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap(); | |
| 308 | assert!(source.contains("actions/**")); | |
| 309 | assert!(source.contains("secrets.G1T_ACTIONS_PUBLISH_TOKEN")); | |
| 310 | // Every action is a composite one with its README, and the copies of | |
| 311 | // the script that finds g1t-runner are the same file. | |
| 312 | let actions = workflows_dir().join("../../actions"); | |
| 313 | let mut finders = Vec::new(); | |
| 314 | for entry in std::fs::read_dir(&actions).unwrap() { | |
| 315 | let dir = entry.unwrap().path(); | |
| 316 | let manifest = std::fs::read_to_string(dir.join("action.yml")).unwrap_or_else(|_| panic!("{} has no action.yml", dir.display())); | |
| 317 | let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap(); | |
| 318 | assert_eq!(manifest["runs"]["using"].as_str(), Some("composite"), "{}", dir.display()); | |
| 319 | assert!(dir.join("README.md").exists(), "{}", dir.display()); | |
| 320 | finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap()); | |
| 321 | } | |
| 322 | assert!(finders.len() >= 2); | |
| 323 | assert!(finders.windows(2).all(|pair| pair[0] == pair[1]), "actions/*/runner.sh differ"); | |
| 324 | let deploy: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap()).unwrap(); | |
| 325 | assert_eq!(deploy["inputs"]["token"]["default"].as_str(), Some("${{ github.token }}")); | |
| 326 | for output in ["url", "deployment-id"] { | |
| 327 | assert!(deploy["outputs"][output]["value"].is_string(), "{output}"); | |
| 328 | } | |
| 329 | } | |
| 330 | ||
| 331 | #[test] | |
| What g1t can't do yet, and an open letter to Cloudflare | 332 | fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() { |
| 333 | let base = read("runner-base.yml"); | |
| 334 | assert!(base.trigger("schedule").is_some()); | |
| 335 | assert!(base.trigger("workflow_dispatch").is_some()); | |
| 336 | assert!(base.trigger("push").unwrap().branches.allows("main")); | |
| 337 | let job = base.jobs.iter().find(|j| j.id == "build").unwrap(); | |
| 338 | assert_eq!(job.runs_on, json!(["self-hosted", "docker"])); | |
| 339 | } | |
| g1t can be checked against GitHub. The Conformance workflow, the same file in `.g1t/workflows/` and `.github/workflows/` (a test keeps them identical), runs by hand on either; each run's report records which runner variables were set and of what kind, every key of the contexts and the event payload with its type, how the workspace is laid out, and the values outputs, `GITHUB_ENV` and `GITHUB_PATH`, multi-line outputs, `working-directory`, job outputs, the matrix, a cache round trip, an artifact round trip, `hashFiles` and the expression functions give. `node scripts/conformance-diff.mjs github.json g1t.json` lists what GitHub has that g1t lacks or has otherwise, and what only g1t has. It reads no secrets. CONTRIBUTING says how to run it, and CI runs its tests. | 340 | |
| 341 | #[test] | |
| 342 | fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() { | |
| 343 | let root = workflows_dir().parent().unwrap().parent().unwrap().to_path_buf(); | |
| 344 | let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap(); | |
| 345 | let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap(); | |
| 346 | assert_eq!(ours, theirs, "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"); | |
| 347 | // It runs only by hand, and reads nothing it could change. | |
| 348 | let workflow = read("conformance.yml"); | |
| 349 | assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["workflow_dispatch"]); | |
| 350 | assert!(!ours.contains("secrets.")); | |
| 351 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.