Skip to content
351 linesCodeBlameRaw

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 Cloudflare1//! 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 Cloudflare5
6use std::path::PathBuf;
7
8use g1t_actions::expr::{self, Scope, Status};
9use g1t_actions::matrix;
10use g1t_actions::workflow::{self, Severity, Workflow};
11use serde_json::{Map, Value, json};
12
13fn workflows_dir() -> PathBuf {
14 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.g1t/workflows")
15}
16
17fn 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'23fn 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-7nm8r931 // 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 Cloudflare48fn 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).
65fn 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.
90fn 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).
96fn 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]
116fn 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.sh128 assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front", "smoke"]);
What g1t can't do yet, and an open letter to Cloudflare129 // 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]
139fn 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 check165 // 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 Cloudflare178 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 check179 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 Cloudflare183
Deploy ends with a smoke test: sign-in, sign-up and the waitlist still work on g1t.sh184 // 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")], &nothing, push.clone(), false));
191
What g1t can't do yet, and an open letter to Cloudflare192 // 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.sh195 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 Cloudflare197}
198
199#[test]
200fn 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]
219fn 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-artifacts223 let on = |rust: bool, image: bool| {
What g1t can't do yet, and an open letter to Cloudflare224 let mut contexts = Map::new();
Merge branch 'main' into actions-toolkit-oidc-artifacts225 contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image }));
What g1t can't do yet, and an open letter to Cloudflare226 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-artifacts229 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 Cloudflare233 }
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-artifacts237 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
What g1t can't do yet, and an open letter to Cloudflare238}
239
Merge remote-tracking branch 'origin/main' into workspace-chat240/// Whether a step runs, for a job going `status`, with `matrix`.
241fn 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]
249fn 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 Cloudflare300#[test]
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9301fn 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 Cloudflare332fn 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]
342fn 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.