Skip to content

Commit

Merge branch 'worktree-agent-ab6bf4195fcb97f7c' into claude/cool-newton-7nm8r9

Claudeagentcommitted Parents793c7a1dc35a8aBrowse files
19 files+587−580/19 viewed
+1−0
7070 args["version"] = json!(body["version"].as_str().unwrap_or_default());
7171 args["running"] = json!(body["running"].as_array().cloned().unwrap_or_default());
7272 args["wait_ms"] = json!(body["wait_ms"].as_u64().unwrap_or(0));
73+ args["isolation"] = json!(body["isolation"].as_str());
7374 "runner_poll"
7475 }
7576 "finished" => {
+15−5
14921492 is true when the commit before the push is not in the new commit's
14931493 history. g1t reads the 20 latest commits to tell, so a forced push onto
14941494 a longer history says `false`. `head_commit` is the commit pushed and
1495− `commits` lists at most 20, oldest first.
1495+ `commits` lists at most 20, oldest first. `pusher.email` is the
1496+ pusher's noreply address (`<id>+<username>@users.noreply.g1t.sh`), or
1497+ `g1t@users.noreply.g1t.sh` for g1t.
1498+- **A commit's `added`, `removed` and `modified`** list the paths the
1499+ push changed when it pushed one commit and a workflow on `push` has a
1500+ `paths` or `paths-ignore` filter, which makes g1t compare the push.
1501+ Otherwise, and for a push of several commits, they are empty lists.
14961502 - **On pull request events,** `pull_request.base.sha` is the tip of the
14971503 branch it merges into when the event happened, and
14981504 `pull_request.merge_commit_sha` is set only once it is merged, to the
15001506 - **On `workflow_run`,** `workflow_run` has the followed run's `name`,
15011507 `path`, `event`, `status`, `conclusion`, `head_branch`, `head_sha`,
15021508 `head_commit`, `run_number`, `run_attempt`, `actor`, `created_at`,
1503− `updated_at`, `html_url`, `repository` and `pull_requests` (each with
1504− its `number` and `url`).
1509+ `updated_at`, `html_url`, `repository` and `pull_requests`. Each pull
1510+ request there has its `id`, `number`, `url`, and `head` and `base`
1511+ with their `ref`, `sha` and `repo` (`id`, `url`, `name`). As on
1512+ GitHub, a pull request from a fork is not listed.
15051513
1506−Labels on `issue` and `pull_request` have their `id` and `name` but no
1507−`color`; a `labeled` event's own `label` has its color.
1514+Labels on `issue`, `pull_request` and a `labeled` event's `label` have
1515+their `id`, `name`, `color` and `description` as the repository has
1516+them, and `default`, which is true for the labels a repository starts
1517+with.
15081518
15091519 ## Checks
15101520
+4−1
322322 uploads exactly as [What deploys](#what-deploys) says; its output is in
323323 the step's log and on the deployment's page. When the build fails, or
324324 g1t does not take what it built, the step fails and the deployment says
325−why.
325+why. When the job ends before the build has finished, because it was
326+cancelled, failed in an earlier step or timed out, the deployment fails
327+as the job ends, saying how it ended, rather than staying **Building**.
326328
327329 - **Secrets.** A pull request from a fork or from someone without the
328330 Write role, and a run that had to be
673675 | g1t/deploy says "The job's token needs `deployments: write`" | Add `deployments: write` to the workflow's or the job's `permissions:`. A pull request's run from outside gets a read-only token and cannot deploy. |
674676 | g1t/deploy says "Name the project to build" | The repository has several projects with Deployments on: give `project:`. |
675677 | g1t/deploy says "g1t.page deploys branches and pull requests" | The run is for a tag. Run the workflow on a branch, such as on a push to `main`. |
678+| "The workflow job was cancelled before the build finished" | The job that opened the build (`g1t/deploy`) was cancelled, or ended another way the message names, before `g1t-runner deploy` reported. Run the job again. |
676679 | "The build did not finish in 45 minutes" | The build stopped reporting: a build stops at 30 minutes, and one not heard from after 45 is failed. Make the build faster, or build less for previews with **Build command**. |
677680
678681 ## Running your own g1t
+15−5
144144 jobs directly on this machine (--no-docker), unsandboxed.*
145145 - Every job's log starts with a warning: *This job ran on the host,
146146 unsandboxed …*
147−- It refuses a public repository's work, which anyone can propose code
148− to: *acme/web is public, and this self-hosted runner runs jobs directly
149− on its machine (--no-docker), unsandboxed, so it does not take a public
150− repository's work.* The job fails with that message.
147+- It is not given a public repository's work, which anyone can propose
148+ code to. The runner says how it is isolated when it registers and on
149+ every poll, and g1t hands a public repository's jobs and agent work
150+ only to runners that are not in host mode. A job no other online runner
151+ could take waits, and its page says *Waiting for a sandboxed runner:
152+ this repository is public, and host-mode runners don't run public
153+ repositories' jobs.* It starts once a sandboxed runner with its labels
154+ comes online.
155+- A runner from before this, which does not say how it is isolated, may
156+ still be given such a job and refuses it itself: *acme/web is public,
157+ and this self-hosted runner runs jobs directly on its machine
158+ (--no-docker), unsandboxed, so it does not take a public repository's
159+ work.* The job fails with that message. Update the runner.
151160
152161 Move a Linux runner to [the container](#run-it-as-a-container). A macOS
153162 or Windows runner stays in host mode until sandboxes for them are built.
550559 | `run` says *g1t no longer knows this runner* | It was removed, or its credential was. Register it again. |
551560 | *this runner is not sandboxed from its machine, so it will not take work* | The runner's container can reach the machine: it lists why. Remove the machine's Docker socket and `--privileged`, and run it with `--runtime=sysbox-runc`. See [run it as a container](#run-it-as-a-container). |
552561 | *the runner's own Docker Engine could not start* | The container is not running under Sysbox. Add `--runtime=sysbox-runc`, and check that Sysbox is installed (`docker info` lists `sysbox-runc` under runtimes). |
553−| *… is public, and this self-hosted runner runs jobs directly on its machine* | A runner in host mode does not take public repositories' work. Run the runner's image under Sysbox. |
562+| The job says *Waiting for a sandboxed runner: this repository is public …* | Every online runner with the job's labels is in host mode, which never runs a public repository's jobs. Run a runner's image under Sysbox, or register one without `--no-docker`. |
563+| *… is public, and this self-hosted runner runs jobs directly on its machine* | An older runner in host mode refused a public repository's job. Update it, so g1t gives the job to a sandboxed runner instead, and run the runner's image under Sysbox. |
554564 | A step's `sudo` fails | Job containers have `no-new-privileges`. Steps run as the image's user, root in most CI images, so drop the `sudo`. |
555565 | *could not run docker* | Install Docker and start it, and give the runner's user access to it. |
556566 | Agent work fails at once | Give the runner `--agent-image`. |
+32−2
361361 /// How the runner keeps work from its machine: `nested-container` (a
362362 /// container per job, inside the runner's own sandboxed container),
363363 /// `container` (a container per job, on a Docker Engine it shares with
364− /// its machine) or `host` (directly on the machine). Kept, and shown
365− /// nowhere yet; older runners leave it out.
364+ /// its machine) or `host` (directly on the machine). A `host` runner is
365+ /// given no public repository's work ([`isolation_allows`]); older
366+ /// runners leave it out.
366367 #[serde(default)]
367368 pub isolation: Option<String>,
368369 }
397398 pub running: Vec<String>,
398399 #[serde(default)]
399400 pub wait_ms: u64,
401+ /// How the runner keeps work from its machine now, as at registration
402+ /// (`RegisterArgs::isolation`); kept up to date from each poll. Older
403+ /// runners leave it out.
404+ #[serde(default)]
405+ pub isolation: Option<String>,
400406 }
401407
402408 /// Work for a runner.
614620 }
615621 }
616622
623+/// What a public repository's job says while the only runners that could
624+/// take it run jobs directly on their machines (`host` isolation), which
625+/// are never given a public repository's work.
626+pub const WAITING_FOR_SANDBOX: &str =
627+ "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs.";
628+
629+/// Whether a runner of `isolation` may be given a repository's work, by
630+/// its visibility (`public` or `private`; unknown lets it through, and the
631+/// runner decides). A host-mode runner takes no public repository's work.
632+pub fn isolation_allows(isolation: Option<&str>, visibility: Option<&str>) -> bool {
633+ !(isolation == Some("host") && visibility == Some("public"))
634+}
635+
617636 /// What a job waiting for a runner says.
618637 pub fn waiting_reason(wanted: &Wanted) -> String {
619638 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
628647 }
629648
630649 #[test]
650+ fn a_host_mode_runner_takes_no_public_repositorys_work() {
651+ assert!(!isolation_allows(Some("host"), Some("public")));
652+ assert!(isolation_allows(Some("host"), Some("private")));
653+ assert!(isolation_allows(Some("host"), None));
654+ assert!(isolation_allows(Some("container"), Some("public")));
655+ assert!(isolation_allows(None, Some("public")), "an older runner decides itself");
656+ let poll: PollArgs = serde_json::from_value(serde_json::json!({ "runner": "rnr_1", "credential": "c" })).unwrap();
657+ assert_eq!(poll.isolation, None, "an older runner's poll");
658+ }
659+
660+ #[test]
631661 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
632662 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
633663 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
+22−2
113113 Ok(serde_json::from_value(answer)?)
114114 }
115115
116− pub fn poll(&self, running: &[String], wait_ms: u64) -> std::result::Result<Poll, Failure> {
117− let body = json!({ "version": VERSION, "running": running, "wait_ms": wait_ms });
116+ /// Asks for work. `isolation` is how this runner keeps work from its
117+ /// machine now, which g1t keeps up to date from each poll (it gives a
118+ /// host-mode runner no public repository's work); an older g1t ignores it.
119+ pub fn poll(&self, running: &[String], wait_ms: u64, isolation: &str) -> std::result::Result<Poll, Failure> {
120+ let body = poll_body(running, wait_ms, isolation);
118121 let answer = post(&format!("{}/runners/{}/poll", self.base, self.runner), Some(&self.credential), &body)?;
119122 serde_json::from_value(answer).map_err(|e| Failure::Unreachable(e.to_string()))
120123 }
149152 std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)?;
150153 Ok(bytes)
151154 }
155+
156+fn poll_body(running: &[String], wait_ms: u64, isolation: &str) -> Value {
157+ json!({ "version": VERSION, "running": running, "wait_ms": wait_ms, "isolation": isolation })
158+}
159+
160+#[cfg(test)]
161+mod tests {
162+ use super::*;
163+
164+ #[test]
165+ fn a_poll_says_how_the_runner_is_isolated() {
166+ let body = poll_body(&["job_1".to_owned()], 0, "host");
167+ assert_eq!(body["isolation"], "host");
168+ assert_eq!(body["running"], json!(["job_1"]));
169+ assert_eq!(body["version"], VERSION);
170+ }
171+}
+1−1
499499 }
500500 let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect();
501501 let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS };
502− let poll = match api.poll(&running, wait) {
502+ let poll = match api.poll(&running, wait, isolation.as_str()) {
503503 Ok(poll) => {
504504 backoff = Duration::from_secs(2);
505505 poll
+182−23
1313 use g1t_actions::events::{account_id, github_id};
1414 use g1t_contracts::{PrincipalKind, User};
1515 use g1t_contracts::repos::{Commit, Repo, RepoPath};
16−use g1t_contracts::work::{Comment, Issue, MilestoneRef, Pull, PullStatus, State};
16+use g1t_contracts::repos::FileStatus;
17+use g1t_contracts::work::{Comment, DEFAULT_LABELS, Issue, Label, MilestoneRef, Pull, PullStatus, State, label_color_for};
1718 use serde_json::{Value, json};
1819
1920 use crate::{API, SITE};
119120 Value::Array(names.iter().map(|name| label(repo, name, None)).collect())
120121 }
121122
122−/// A label, by its name in `repo`.
123+/// A label, by its name in `repo`. Its `color` is the one g1t gives a
124+/// label of that name until [`color_labels`] sets the repository's own;
125+/// `default` is whether it is one of the labels a repository starts with.
123126 fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value {
124− let mut label = json!({ "id": github_id(&format!("{}:label:{name}", repo.id)), "name": name });
125− if let Some(color) = color {
126− label["color"] = color.clone();
127+ json!({
128+ "id": github_id(&format!("{}:label:{name}", repo.id)),
129+ "node_id": format!("{}:label:{name}", repo.id),
130+ "name": name,
131+ "color": color.filter(|c| c.is_string()).cloned().unwrap_or_else(|| json!(label_color_for(name))),
132+ "description": DEFAULT_LABELS.iter().find(|(known, _, _)| *known == name).map(|(_, _, about)| *about),
133+ "default": DEFAULT_LABELS.iter().any(|(known, _, _)| *known == name),
134+ })
135+}
136+
137+/// Gives every label in a payload (`labels[]` and `label`) the color and
138+/// description the repository has for it (work's `list_labels`).
139+pub fn color_labels(payload: &mut Value, known: &[Label]) {
140+ fn set(label: &mut Value, known: &[Label]) {
141+ let Some(found) = label["name"].as_str().and_then(|name| known.iter().find(|k| k.name == name)) else { return };
142+ label["color"] = json!(found.color);
143+ label["description"] = if found.description.is_empty() { Value::Null } else { json!(found.description) };
144+ }
145+ if let Value::Object(map) = payload {
146+ for (key, value) in map.iter_mut() {
147+ match (key.as_str(), value) {
148+ ("labels", Value::Array(labels)) => labels.iter_mut().for_each(|label| set(label, known)),
149+ ("label", label @ Value::Object(_)) => set(label, known),
150+ (_, value) => color_labels(value, known),
151+ }
152+ }
153+ }
154+}
155+
156+/// Whether a payload has any label, so its colors are worth reading.
157+pub fn has_labels(payload: &Value) -> bool {
158+ match payload {
159+ Value::Object(map) => map.iter().any(|(key, value)| match (key.as_str(), value) {
160+ ("labels", Value::Array(labels)) => !labels.is_empty(),
161+ ("label", Value::Object(_)) => true,
162+ (_, value) => has_labels(value),
163+ }),
164+ _ => false,
127165 }
128− label
129166 }
130167
131168 /// A milestone as an issue or a pull request names it.
158195 })
159196 }
160197
198+/// Sets a one-commit push's `added`, `removed` and `modified` (on its
199+/// commit and `head_commit`) from what the push changed, compared as its
200+/// `paths` filters are. A push of several commits keeps them empty: the
201+/// comparison is of the whole push, not of each commit.
202+pub fn push_changes(payload: &mut Value, changes: &[(String, FileStatus)]) {
203+ if payload["commits"].as_array().map(Vec::len) != Some(1) {
204+ return;
205+ }
206+ let of = |status: FileStatus| -> Value {
207+ changes.iter().filter(|(_, s)| *s == status).map(|(path, _)| json!(path)).collect::<Vec<_>>().into()
208+ };
209+ for commit in ["commits", "head_commit"] {
210+ let commit = if commit == "commits" { &mut payload["commits"][0] } else { &mut payload["head_commit"] };
211+ commit["added"] = of(FileStatus::Added);
212+ commit["removed"] = of(FileStatus::Deleted);
213+ commit["modified"] = of(FileStatus::Modified);
214+ }
215+}
216+
161217 /// A push. `forced` is whether `before` is not in `after`'s history, when
162218 /// that is known; a push whose history is too long to read says `false`.
163−pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str, forced: bool) -> Value {
219+/// `pusher_email` is the pusher's noreply address, when the push was a
220+/// person's.
221+#[allow(clippy::too_many_arguments)]
222+pub fn push(
223+ repo: &Repo,
224+ git_ref: &str,
225+ before: Option<&str>,
226+ after: &str,
227+ commits: &[Commit],
228+ pusher: &str,
229+ pusher_email: Option<&str>,
230+ forced: bool,
231+) -> Value {
164232 let before = before.unwrap_or(ZERO);
165233 let created = before == ZERO;
166234 let deleted = after == ZERO;
183251 // GitHub's head commit is the one pushed: the last.
184252 "head_commit": commits.last().cloned().unwrap_or(Value::Null),
185253 "commits": commits,
186− "pusher": { "name": pusher, "email": null },
254+ "pusher": { "name": pusher, "email": pusher_email },
187255 "repository": repository(repo),
188256 "sender": user(pusher),
189257 })
420488 pub triggering_actor: Option<&'a str>,
421489 pub title: Option<&'a str>,
422490 pub head_commit: Option<&'a Commit>,
491+ /// The pull request the run was for (`data.pull`), when it can be
492+ /// read, and its base branch's latest commit.
493+ pub pull: Option<&'a Pull>,
494+ pub base_sha: Option<&'a str>,
423495 }
424496
497+/// The run's pull request as `workflow_run.pull_requests[]` gives it. One
498+/// from a fork is left out, as on GitHub, where only pull requests whose
499+/// head is in the repository are listed.
500+fn run_pulls(repo: &Repo, run: &FollowedRun) -> Vec<Value> {
501+ let full_name = format!("{}/{}", repo.namespace, repo.name);
502+ let Some(number) = run.data["pull"].as_u64() else { return Vec::new() };
503+ let url = format!("{API}/repos/{full_name}/pulls/{number}");
504+ let Some(pull) = run.pull else {
505+ return vec![json!({ "number": number, "url": url })];
506+ };
507+ if pull.fork.is_some() {
508+ return Vec::new();
509+ }
510+ let short = json!({ "id": github_id(&repo.id), "url": format!("{API}/repos/{full_name}"), "name": repo.name });
511+ vec![json!({
512+ "id": github_id(&pull.id),
513+ "number": number,
514+ "url": url,
515+ "head": { "ref": head_ref(pull), "sha": pull.head_commit, "repo": short },
516+ "base": {
517+ "ref": pull.base_branch(&repo.default_branch),
518+ "sha": run.base_sha.map(str::to_owned).or_else(|| pull.merge_base.clone()),
519+ "repo": short,
520+ },
521+ })]
522+}
523+
425524 pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value {
426525 let full_name = format!("{}/{}", repo.namespace, repo.name);
427526 let data = run.data;
463562 "logs_url": format!("{url}/logs"),
464563 "artifacts_url": format!("{url}/artifacts"),
465564 "workflow_url": run.workflow_id.map(|w| format!("{API}/repos/{full_name}/actions/workflows/{w}")),
466− "pull_requests": data["pull"].as_u64().map(|n| vec![json!({
467− "number": n,
468− "url": format!("{API}/repos/{full_name}/pulls/{n}"),
469− })]).unwrap_or_default(),
565+ "pull_requests": run_pulls(repo, run),
470566 "repository": repository(repo),
471567 "head_repository": repository(repo),
472568 })
586682 ("workflow_run", "workflow_run.id", "string"),
587683 ("workflow_run", "workflow_run.workflow_id", "string"),
588684 ("workflow_run", "workflow.id", "string"),
589− // g1t keeps labels by name on issues and pull requests; only a
590− // `labeled` event's own `label` has its color.
591− ("pull_request", "pull_request.labels[].color", "absent"),
592− ("issues", "issue.labels[].color", "absent"),
593− // A push names who pushed, not their address.
594− ("push", "pusher.email", "null"),
595685 ];
596686
597687 fn kind(value: &Value) -> &'static str {
705795 fn payloads() -> Vec<(&'static str, Value)> {
706796 let repo = repo();
707797 let pull = a_pull();
708− let push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", false);
709− let pull_request = json!({
798+ let mut push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", Some("0d0efg+ada@users.noreply.g1t.sh"), false);
799+ push_changes(&mut push, &[("README.md".into(), FileStatus::Modified)]);
800+ let known = [Label { name: "bug".into(), color: "d73a4a".into(), description: "Something isn't working".into(), issues: 1, pulls: 1 }];
801+ let mut pull_request = json!({
710802 "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull),
711803 "repository": repository(&repo), "sender": user("ada"),
712804 });
805+ color_labels(&mut pull_request, &known);
713806 let review = json!({
714807 "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))),
715808 "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"),
716809 });
717810 let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") });
718811 changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } }));
812+ color_labels(&mut issues, &known);
719813 let mut on_pull = pull.clone();
720814 on_pull.labels.clear();
721815 on_pull.assignees.clear();
742836 triggering_actor: None,
743837 title: Some("Update README.md"),
744838 head_commit: Some(&head),
839+ pull: Some(&pull),
840+ base_sha: Some("f95f852bd8fca8fcc58a9a2d6c842781e32a215e"),
745841 };
746842 // As trigger.rs gives it.
747843 let workflow_run = json!({
845941 fn a_push_says_whether_it_made_deleted_or_forced_its_ref() {
846942 let repo = repo();
847943 let after = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c";
848− let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", false);
944+ let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", None, false);
849945 assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false)));
850946 assert_eq!(made["before"], ZERO);
851− let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", true);
947+ let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", None, true);
852948 assert_eq!(forced["forced"], true);
853949 assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}"));
854950 assert_eq!(forced["head_commit"]["id"], after);
855− let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", true);
951+ let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", None, true);
856952 assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false)));
857953 }
858954
859955 #[test]
956+ fn a_one_commit_push_lists_what_it_changed() {
957+ let repo = repo();
958+ let changes = [
959+ ("new.txt".to_owned(), FileStatus::Added),
960+ ("README.md".to_owned(), FileStatus::Modified),
961+ ("old.txt".to_owned(), FileStatus::Deleted),
962+ ];
963+ let mut one = push(&repo, "refs/heads/main", None, &a_commit().hash, &[a_commit()], "ada", None, false);
964+ push_changes(&mut one, &changes);
965+ for commit in [&one["commits"][0], &one["head_commit"]] {
966+ assert_eq!(commit["added"], json!(["new.txt"]));
967+ assert_eq!(commit["modified"], json!(["README.md"]));
968+ assert_eq!(commit["removed"], json!(["old.txt"]));
969+ }
970+ // Several commits: the comparison is not any one commit's.
971+ let mut two = push(&repo, "refs/heads/main", None, &a_commit().hash, &[a_commit(), a_commit()], "ada", None, false);
972+ push_changes(&mut two, &changes);
973+ assert_eq!(two["head_commit"]["modified"], json!([]));
974+ }
975+
976+ #[test]
977+ fn labels_have_the_repositorys_colors() {
978+ let repo = repo();
979+ let mut event = json!({ "issue": issue(&repo, &an_issue()), "label": label(&repo, "bug", None) });
980+ // Before the repository's are known, g1t's default for the name.
981+ assert_eq!(event["issue"]["labels"][0]["color"], "d73a4a");
982+ assert_eq!(event["issue"]["labels"][0]["default"], true);
983+ assert!(has_labels(&event));
984+ let known = [Label { name: "bug".into(), color: "123456".into(), description: String::new(), issues: 1, pulls: 0 }];
985+ color_labels(&mut event, &known);
986+ assert_eq!(event["issue"]["labels"][0]["color"], "123456");
987+ assert_eq!(event["issue"]["labels"][0]["description"], Value::Null);
988+ assert_eq!(event["label"]["color"], "123456");
989+ assert!(!has_labels(&json!({ "issue": { "labels": [] } })));
990+ }
991+
992+ #[test]
993+ fn a_run_names_its_pull_request_unless_it_is_from_a_fork() {
994+ let repo = repo();
995+ let data = json!({ "runId": "run_1", "pull": 2 });
996+ let pull = a_pull();
997+ let run = |pull: Option<&Pull>| {
998+ workflow_run(&repo, &FollowedRun {
999+ data: &data, workflow_id: None, attempt: None, created_at: None, started_at: None, updated_at: None,
1000+ actor: None, triggering_actor: None, title: None, head_commit: None, pull, base_sha: Some("b4se"),
1001+ })
1002+ };
1003+ let named = run(Some(&pull));
1004+ let first = &named["pull_requests"][0];
1005+ assert_eq!(first["number"], 2);
1006+ assert_eq!(first["id"], json!(github_id(&pull.id)));
1007+ assert_eq!(first["head"]["ref"], "changes");
1008+ assert_eq!(first["head"]["sha"], "ec26c3e57ca3a959ca5aad62de7213c562f8c821");
1009+ assert_eq!(first["base"]["ref"], "main");
1010+ assert_eq!(first["base"]["sha"], "b4se");
1011+ assert_eq!(first["base"]["repo"]["id"], json!(github_id(&repo.id)));
1012+ let mut forked = a_pull();
1013+ forked.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
1014+ assert_eq!(run(Some(&forked))["pull_requests"], json!([]));
1015+ assert_eq!(run(None)["pull_requests"][0]["number"], 2, "unread, still numbered");
1016+ }
1017+
1018+ #[test]
8601019 fn a_pull_request_from_a_fork_names_the_fork() {
8611020 let mut pull = a_pull();
8621021 pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
+18−0
16801680 let Some(job) = finished else { return Ok(()) };
16811681 // Its G1T_TOKEN stops working with it.
16821682 self.revoke_job_tokens(&job.id).await;
1683+ // A g1t.page build it opened and did not finish fails now, not at
1684+ // the deployments sweep. Only a job that ran can have opened one.
1685+ if job.started_at.is_some() {
1686+ self.end_job_builds(&job.id, conclusion).await;
1687+ }
16831688 // A job that deploys failed: so did its run's deployment, now.
16841689 if conclusion == "failure" && job.continue_on_error == 0 && job.started_at.is_some()
16851690 && let Some(run) = self.run_row(&job.run_id).await?
17741779 .await?;
17751780 if stopped.as_ref().is_some_and(|row| row.started_at.is_some()) {
17761781 self.revoke_job_tokens(&job.id).await;
1782+ self.end_job_builds(&job.id, "cancelled").await;
17771783 }
17781784 if let Some(stopped) = stopped.filter(|row| row.runner_id.is_some()) {
17791785 self.released(&stopped).await?;
18711877 Ok(())
18721878 }
18731879
1880+ /// Tells the deployments service that a job ended (`workflow_job_ended`),
1881+ /// so a build it opened with `start_from_workflow` and left open fails
1882+ /// at once. Never fails the job: an answer that does not come is
1883+ /// logged, and the sweep fails the build later.
1884+ async fn end_job_builds(&self, job_id: &str, conclusion: &str) {
1885+ let ended: Result<Value> =
1886+ g1t_kit::call(&self.deployments, "workflow_job_ended", &json!({ "jobId": job_id, "conclusion": conclusion })).await;
1887+ if let Err(error) = ended {
1888+ worker::console_log!("actions: could not tell deployments that job {job_id} ended: {error}");
1889+ }
1890+ }
1891+
18741892 /// Tells the deployments service how a run's deployment to `env` stands:
18751893 /// made when its first job naming the environment starts, failed when one
18761894 /// of them fails, and settled (`last`) when the run finishes. Never fails
+137−5
3232 use crate::plan::JobRow;
3333 use crate::{Actions, Count, SILENT_MS, check, fail, optional};
3434
35+/// What one poll has learned while it looks for work, so its looks again
36+/// do not ask again: each repository's visibility, and the jobs it already
37+/// said wait for a sandboxed runner.
38+#[derive(Default)]
39+struct Looked {
40+ visibilities: std::collections::HashMap<String, Option<String>>,
41+ noted: std::collections::HashSet<String>,
42+}
43+
3544 /// How often a poll looks again for work while it waits.
3645 const POLL_EVERY_MS: u64 = 2_500;
3746 /// A runner that has been offline this long is removed, as on GitHub.
6978 pub last_seen_at: Option<String>,
7079 pub created_at: String,
7180 pub created_by: Option<String>,
81+ /// `nested-container`, `container` or `host` (migration 0015); none
82+ /// for a runner that never said.
83+ #[serde(default)]
84+ pub isolation: Option<String>,
7285 }
7386
7487 impl RunnerRow {
953966 let version: String = if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() };
954967 let work_id = active.as_ref().map(|(id, _)| id.as_str());
955968 let work_kind = active.as_ref().map(|(_, kind)| *kind);
956− let changed = version != row.version || work_id != row.work_id.as_deref() || work_kind != row.work_kind.as_deref();
969+ // How it is isolated, as it says now: a runner re-registered or
970+ // restarted with or without Docker says so on its next poll.
971+ let isolation = a
972+ .isolation
973+ .as_deref()
974+ .filter(|level| model::ISOLATIONS.contains(level))
975+ .map(str::to_owned)
976+ .or_else(|| row.isolation.clone());
977+ let changed = version != row.version
978+ || work_id != row.work_id.as_deref()
979+ || work_kind != row.work_kind.as_deref()
980+ || isolation != row.isolation;
957981 if changed || seen_is_stale(row.last_seen_at.as_deref(), at) {
958982 self.db
959− .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ? WHERE id = ?")
960− .bind(&[seen.as_str().into(), version.into(), optional(work_id), optional(work_kind), row.id.as_str().into()])?
983+ .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ?, isolation = ? WHERE id = ?")
984+ .bind(&[
985+ seen.as_str().into(),
986+ version.into(),
987+ optional(work_id),
988+ optional(work_kind),
989+ optional(isolation.as_deref()),
990+ row.id.as_str().into(),
991+ ])?
961992 .run()
962993 .await?;
963994 }
995+ let row = RunnerRow { isolation, ..row };
964996 let mut poll = Poll { assignment: None, cancel, credential, removed: false };
965997 // Busy, or an ephemeral runner that has had its one job.
966998 if active.is_some() || !a.running.is_empty() {
9741006 let groups = self.group_rows(&row.workspace).await?;
9751007 let group = row.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id)).cloned();
9761008 let deadline = at + a.wait_ms.min(model::MAX_POLL_WAIT_MS);
1009+ let mut looked = Looked::default();
9771010 loop {
978− if let Some(assignment) = self.claim(&row, group.as_ref()).await? {
1011+ if let Some(assignment) = self.claim(&row, group.as_ref(), &mut looked).await? {
9791012 poll.assignment = Some(assignment);
9801013 return Ok(Outcome::Ok(poll));
9811014 }
9871020 }
9881021
9891022 /// Takes the oldest work this runner can do, if there is any.
990− async fn claim(&self, runner: &RunnerRow, group: Option<&GroupRow>) -> Result<Option<Assignment>> {
1023+ async fn claim(&self, runner: &RunnerRow, group: Option<&GroupRow>, looked: &mut Looked) -> Result<Option<Assignment>> {
9911024 let labels = runner.labels();
9921025 let group_name = group.map(|g| g.name.clone());
9931026 // Workflow jobs.
10211054 if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&run_repo)) {
10221055 continue;
10231056 }
1057+ // A host-mode runner takes no public repository's job: it waits
1058+ // for a sandboxed runner, and says so when none could take it.
1059+ if runner.isolation.as_deref() == Some("host") {
1060+ let visibility = self.visibility_once(looked, &job.repo_id).await;
1061+ if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) {
1062+ if looked.noted.insert(job.id.clone()) {
1063+ self.wait_for_sandbox(&job, &run_repo, &wanted).await?;
1064+ }
1065+ continue;
1066+ }
1067+ }
10241068 if let Some(max) = job.max_parallel {
10251069 let siblings = self
10261070 .db
11031147 if !wanted.matches(&labels, None) {
11041148 continue;
11051149 }
1150+ // Nor a public repository's agent work.
1151+ if runner.isolation.as_deref() == Some("host")
1152+ && let Some(repo_id) = &task.repo_id
1153+ {
1154+ let visibility = self.visibility_once(looked, repo_id).await;
1155+ if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) {
1156+ continue;
1157+ }
1158+ }
11061159 if let Some(repo_id) = &runner.repo_id
11071160 && task.repo_id.as_deref() != Some(repo_id.as_str())
11081161 {
11681221 Ok(None)
11691222 }
11701223
1224+ /// [`Self::visibility`], asked once per repository in a poll.
1225+ async fn visibility_once(&self, looked: &mut Looked, repo_id: &str) -> Option<String> {
1226+ if let Some(known) = looked.visibilities.get(repo_id) {
1227+ return known.clone();
1228+ }
1229+ let found = self.visibility(repo_id).await;
1230+ looked.visibilities.insert(repo_id.to_owned(), found.clone());
1231+ found
1232+ }
1233+
1234+ /// A public repository's job that a host-mode runner passed over: when
1235+ /// no online runner that may take it could (its labels, group,
1236+ /// repository, and isolation), its page says it waits for a sandboxed
1237+ /// one. Written only when that changes what it says.
1238+ async fn wait_for_sandbox(&self, job: &JobRow, run_repo: &str, wanted: &Wanted) -> Result<()> {
1239+ let at = now_ms();
1240+ let groups = self.group_rows(&job.namespace.to_lowercase()).await?;
1241+ let runners = self.runner_rows(&job.namespace.to_lowercase()).await?;
1242+ let sandboxed = runners.iter().any(|other| takes_public_job(other, &groups, &job.repo_id, run_repo, wanted, at));
1243+ if sandboxed {
1244+ return Ok(());
1245+ }
1246+ self.db
1247+ .prepare("UPDATE jobs SET reason = ? WHERE id = ? AND status = 'queued' AND reason IS NOT ?")
1248+ .bind(&[model::WAITING_FOR_SANDBOX.into(), job.id.as_str().into(), model::WAITING_FOR_SANDBOX.into()])?
1249+ .run()
1250+ .await?;
1251+ Ok(())
1252+ }
1253+
11711254 /// A repository's visibility for a runner, `public` or `private`; none
11721255 /// when it cannot be read, and the runner decides without it.
11731256 async fn visibility(&self, repo_id: &str) -> Option<String> {
16051688 id: String,
16061689 }
16071690
1691+/// Whether `runner` is online and could take a public repository's job:
1692+/// not host-mode, and its repository, group and labels let it.
1693+fn takes_public_job(runner: &RunnerRow, groups: &[GroupRow], repo_id: &str, run_repo: &str, wanted: &Wanted, at: u64) -> bool {
1694+ let group = runner.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id));
1695+ runner.online(at)
1696+ && model::isolation_allows(runner.isolation.as_deref(), Some("public"))
1697+ && runner.repo_id.as_deref().is_none_or(|repo| repo == repo_id)
1698+ && (runner.repo_id.is_some() || group.is_none_or(|g| g.allows(run_repo)))
1699+ && wanted.matches(&runner.labels(), group.map(|g| g.name.as_str()))
1700+}
1701+
16081702 fn group_view(group: &GroupRow, runners: &[RunnerRow]) -> RunnerGroup {
16091703 RunnerGroup {
16101704 id: group.id.clone(),
16611755 }
16621756
16631757 #[test]
1758+ fn only_a_sandboxed_runner_that_could_take_it_serves_a_public_job() {
1759+ let at = 1_000_000_000;
1760+ let runner = |isolation: Option<&str>| RunnerRow {
1761+ id: "rnr_1".into(),
1762+ workspace: "acme".into(),
1763+ repo_id: None,
1764+ repo: None,
1765+ group_id: None,
1766+ name: "a".into(),
1767+ labels: r#"["self-hosted","linux","x64"]"#.into(),
1768+ os: "linux".into(),
1769+ arch: "x64".into(),
1770+ version: String::new(),
1771+ ephemeral: 0,
1772+ credential_hash: String::new(),
1773+ previous_hash: None,
1774+ rotated_at: String::new(),
1775+ work_id: None,
1776+ work_kind: None,
1777+ spent: 0,
1778+ last_seen_at: Some(rfc3339(at)),
1779+ created_at: String::new(),
1780+ created_by: None,
1781+ isolation: isolation.map(str::to_owned),
1782+ };
1783+ let wanted = Wanted::of(&["self-hosted".to_owned(), "linux".to_owned()], None);
1784+ let takes = |row: &RunnerRow| takes_public_job(row, &[], "rep_1", "acme/web", &wanted, at);
1785+ assert!(!takes(&runner(Some("host"))));
1786+ assert!(takes(&runner(Some("container"))));
1787+ assert!(takes(&runner(Some("nested-container"))));
1788+ assert!(takes(&runner(None)), "an older runner, which refuses for itself");
1789+ assert!(!takes(&RunnerRow { repo_id: Some("rep_2".into()), ..runner(None) }), "another repository's");
1790+ assert!(!takes(&RunnerRow { labels: r#"["self-hosted","macos"]"#.into(), ..runner(None) }));
1791+ assert!(!takes(&RunnerRow { last_seen_at: None, ..runner(None) }), "offline");
1792+ }
1793+
1794+ #[test]
16641795 fn online_means_seen_in_the_last_ninety_seconds() {
16651796 let row = RunnerRow {
16661797 id: "rnr_1".into(),
16831814 last_seen_at: Some(rfc3339(1_000_000_000)),
16841815 created_at: String::new(),
16851816 created_by: None,
1817+ isolation: None,
16861818 };
16871819 assert!(row.online(1_000_000_000 + 60_000));
16881820 assert!(!row.online(1_000_000_000 + 120_000));
+77−9
88 use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun};
99 use g1t_contracts::events::{Event, caused_by_job};
1010 use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
11−use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
12−use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
11+use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
12+use g1t_contracts::work::{IssueDetail, Label, PullDetail, ViewArgs};
1313 use g1t_contracts::{FailureCode, Outcome, User, new_id};
1414 use g1t_contracts::time::rfc3339;
1515 use g1t_kit::now_ms;
5353 merge: Option<String>,
5454 }
5555
56+impl Subject {
57+ /// What the event changes, once compared: the paths for `paths`
58+ /// filters, and a push's commit's `added`, `removed` and `modified`.
59+ fn changed(&mut self, changes: Vec<(String, FileStatus)>) {
60+ payload::push_changes(&mut self.payload, &changes);
61+ self.paths = Some(changes.into_iter().map(|(path, _)| path).collect());
62+ }
63+}
64+
65+/// The address a push's `pusher.email` gives: g1t's for its agent and
66+/// itself, a person's noreply address (which needs no lookup) for them,
67+/// and none for anyone else.
68+fn pusher_email(actor: Option<&str>, username: &str) -> Option<String> {
69+ match actor? {
70+ id if id == AGENT_ID || id == g1t_contracts::system::ID => Some(g1t_contracts::system::EMAIL.to_owned()),
71+ id if id.starts_with("usr_") => Some(g1t_contracts::accounts::noreply_address(id, username)),
72+ _ => None,
73+ }
74+}
75+
5676 /// Whether a deployment's `ref` is a commit's full hash rather than a
5777 /// branch or tag.
5878 fn is_commit(name: &str) -> bool {
206226 Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()))
207227 }
208228
209− async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
229+ /// What `head` changes from `base`: each path and how.
230+ async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<(String, FileStatus)>> {
210231 let compared: Outcome<Comparison> = g1t_kit::call(
211232 &self.repos,
212233 "compare",
219240 },
220241 )
221242 .await?;
222− Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| f.path).collect()).unwrap_or_default())
243+ Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default())
223244 }
224245
246+ /// The repository's labels, for their colors, when `payload` has any.
247+ async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> {
248+ if !payload::has_labels(payload) {
249+ return Ok(());
250+ }
251+ let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 };
252+ let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?;
253+ if let Outcome::Ok(labels) = labels {
254+ payload::color_labels(payload, &labels);
255+ }
256+ Ok(())
257+ }
258+
225259 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
226260 self.branch_head(repo, &repo.default_branch).await
227261 }
318352 let before = data["before"].as_str();
319353 let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?;
320354 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
321− let payload = payload::push(repo, git_ref, before, after, &commits, sender, forced);
355+ let email = pusher_email(event.actor.as_deref(), sender);
356+ let payload = payload::push(repo, git_ref, before, after, &commits, sender, email.as_deref(), forced);
322357 Some(Subject {
323358 source: path.clone(),
324359 source_ref: Some(after.to_owned()),
408443 "sender": payload::user(sender),
409444 });
410445 payload::changed(repo, &mut payload, data);
446+ self.label_colors(repo, ws, &mut payload).await?;
411447 // `base.sha` is the base branch's tip now, as GitHub gives
412448 // it, not where the two last agreed.
413449 if let Some(tip) = self.branch_head(repo, &base_ref).await? {
521557 Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head),
522558 None => None,
523559 };
560+ // Its pull request, for `pull_requests[]`.
561+ let pull = match data["pull"].as_u64() {
562+ Some(number) => match g1t_kit::call::<_, Outcome<PullDetail>>(&self.work, "get_pull", &view(number as u32)).await? {
563+ Outcome::Ok(detail) => Some(detail.pull),
564+ Outcome::Fail(_) => None,
565+ },
566+ None => None,
567+ };
568+ let base_sha = match &pull {
569+ Some(pull) if pull.fork.is_none() => self.branch_head(repo, pull.base_branch(&repo.default_branch)).await?,
570+ _ => None,
571+ };
524572 let followed = payload::FollowedRun {
573+ pull: pull.as_ref(),
574+ base_sha: base_sha.as_deref(),
525575 data,
526576 workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()),
527577 attempt: row.as_ref().map(|r| r.attempt),
574624 "sender": payload::user(sender),
575625 });
576626 payload::changed(repo, &mut payload, data);
627+ self.label_colors(repo, ws, &mut payload).await?;
577628 if event_name == "issue_comment" {
578629 let comment_id = data["commentId"].as_str();
579630 if action == Some("deleted") {
764815 if copied_in && matches!(event_name, "push" | "create") {
765816 if subject.paths.is_none() {
766817 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
767− subject.paths = Some(self.changed_paths(&repo, &ws, base, head).await?);
818+ subject.changed(self.changed_paths(&repo, &ws, base, head).await?);
768819 }
769820 if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default())
770821 && let Some(mirror) = &repo.mirror
818869 actor_id: Option<&str>,
819870 sender: &str,
820871 ) -> Result<()> {
821− for file in read.files {
822− let parsed = workflow::parse(&file.source);
872+ let parsed: Vec<_> = read.files.into_iter().map(|file| (workflow::parse(&file.source), file)).collect();
873+ // A push some workflow filters by path is compared before any run
874+ // starts, so every run's payload lists what its commit changed.
875+ let filters_paths = |(parsed, _): &(std::result::Result<Workflow, _>, _)| {
876+ parsed.as_ref().ok().and_then(|w: &Workflow| w.trigger(event_name)).is_some_and(|t| t.paths.is_set())
877+ };
878+ if event_name == "push" && subject.paths.is_none() && parsed.iter().any(filters_paths) {
879+ let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
880+ subject.changed(self.changed_paths(repo, ws, base, head).await?);
881+ }
882+ for (parsed, file) in parsed {
823883 let workflow = match parsed {
824884 Ok(workflow) => workflow,
825885 Err(problem) => {
904964 if trigger.paths.is_set() {
905965 if subject.paths.is_none() {
906966 let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone()));
907− subject.paths = Some(self.changed_paths(repo, ws, base, head).await?);
967+ subject.changed(self.changed_paths(repo, ws, base, head).await?);
908968 }
909969 if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) {
910970 return Ok(false);
14531513 use g1t_contracts::{Membership, PrincipalKind};
14541514
14551515 #[test]
1516+ fn a_pushers_email_is_their_noreply_address() {
1517+ assert_eq!(pusher_email(Some("usr_01j9zq4m8x7k2v5n3b6c1d0efg"), "Ada").as_deref(), Some("6c1d0efg+ada@users.noreply.g1t.sh"));
1518+ assert_eq!(pusher_email(Some(AGENT_ID), AGENT_NAME).as_deref(), Some(g1t_contracts::system::EMAIL));
1519+ assert_eq!(pusher_email(Some("wsp_1"), "acme"), None);
1520+ assert_eq!(pusher_email(None, "acme"), None);
1521+ }
1522+
1523+ #[test]
14561524 fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() {
14571525 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
14581526 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
+2−2
99 "state": "open",
1010 "state_reason": null,
1111 "locked": false,
12− "labels": [{ "id": 1362934389, "name": "bug", "color": "d73a4a" }],
12+ "labels": [{ "id": 1362934389, "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", "name": "bug", "color": "d73a4a", "description": "Something isn't working", "default": true }],
1313 "milestone": { "id": 4317517, "number": 1, "title": "v1.0", "html_url": "https://github.com/acme/web/milestone/1" },
1414 "user": { "login": "ada", "id": 21031068, "node_id": "MDQ6VXNlcjIxMDMxMDY4", "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
1515 "assignee": { "login": "bo", "id": 21031069, "type": "User", "html_url": "https://github.com/bo", "url": "https://api.github.com/users/bo" },
2222 "url": "https://api.github.com/repos/acme/web/issues/1",
2323 "repository_url": "https://api.github.com/repos/acme/web"
2424 },
25− "label": { "id": 1362934389, "name": "bug", "color": "d73a4a" },
25+ "label": { "id": 1362934389, "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", "name": "bug", "color": "d73a4a", "description": "Something isn't working", "default": true },
2626 "repository": { "id": 186853002, "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", "full_name": "acme/web", "private": false, "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
2727 "sender": { "login": "ada", "id": 21031068, "type": "User" }
2828 }
+1−1
1515 "merged_by": null,
1616 "merge_commit_sha": null,
1717 "labels": [
18− { "id": 1362934389, "name": "bug", "color": "d73a4a" }
18+ { "id": 1362934389, "node_id": "MDU6TGFiZWwxMzYyOTM0Mzg5", "name": "bug", "color": "d73a4a", "description": "Something isn't working", "default": true }
1919 ],
2020 "milestone": {
2121 "id": 4317517,
+15−1
3434 "artifacts_url": "https://api.github.com/repos/acme/web/actions/runs/30433642/artifacts",
3535 "workflow_url": "https://api.github.com/repos/acme/web/actions/workflows/159038",
3636 "pull_requests": [
37− { "number": 2, "url": "https://api.github.com/repos/acme/web/pulls/2" }
37+ {
38+ "url": "https://api.github.com/repos/acme/web/pulls/2",
39+ "id": 279147437,
40+ "number": 2,
41+ "head": {
42+ "ref": "changes",
43+ "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821",
44+ "repo": { "id": 186853002, "url": "https://api.github.com/repos/acme/web", "name": "web" }
45+ },
46+ "base": {
47+ "ref": "main",
48+ "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e",
49+ "repo": { "id": 186853002, "url": "https://api.github.com/repos/acme/web", "name": "web" }
50+ }
51+ }
3852 ],
3953 "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
4054 "head_repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } }
+3−0
1+-- A workflow job's open builds are found by its id when it ends
2+-- (`workflow_job_ended`), to be failed if the job did not finish them.
3+CREATE INDEX IF NOT EXISTS deployments_job ON deployments (job_id) WHERE job_id IS NOT NULL;
+21−1
9191 import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
9292 import { appHost, appUrl, label, uniqueLabel } from "./names";
9393 import { RepoDeployments, sourceOf } from "./repo-deployments";
94−import { NEEDS_WRITE, commitOf, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
94+import { NEEDS_WRITE, commitOf, jobEndedMessage, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
9595
9696 type Env = {
9797 DB: D1Database;
10301030 });
10311031 }
10321032
1033+ /**
1034+ * For the actions service, when a workflow job ends (`finish_job`), with
1035+ * any conclusion: a build the job opened and did not finish or fail is
1036+ * failed now, saying how the job ended, rather than left building until
1037+ * the sweep's 45 minutes. A build the job finished is left as it is.
1038+ */
1039+ async workflowJobEnded(a: { jobId?: unknown; conclusion?: unknown }): Promise<Result<number>> {
1040+ if (typeof a.jobId !== "string" || !a.jobId) return fail("invalid", "No job.");
1041+ const open = await this.db
1042+ .prepare("SELECT id FROM deployments WHERE job_id = ? AND status IN ('queued', 'building')")
1043+ .bind(a.jobId)
1044+ .all<{ id: string }>();
1045+ const message = jobEndedMessage(typeof a.conclusion === "string" ? a.conclusion : "");
1046+ for (const { id } of open.results) await this.finishFailed(id, message, null, null);
1047+ return ok(open.results.length);
1048+ }
1049+
10331050 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
10341051 const settings = await this.settingsRow(project.id);
10351052 if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
24292446 // For the API: a g1t Actions job opening a build it runs itself.
24302447 case "start_from_workflow":
24312448 return service.startFromWorkflow(args);
2449+ // For the actions service: a job ended, so its open builds end too.
2450+ case "workflow_job_ended":
2451+ return service.workflowJobEnded(args);
24322452 case "take_down":
24332453 return service.takeDown(args);
24342454 case "overview":
+17−0
304304 assert.equal(await refused({}), "conflict");
305305 assert.ok(!w.state.calls.includes("RUNNER start_deploy"));
306306 });
307+
308+test("a job that ends without finishing its build fails the build at once, and leaves a finished one be", async () => {
309+ const build = (await open()).value;
310+ const ended = await post("/rpc/workflow_job_ended", { jobId: "job_1", conclusion: "cancelled" });
311+ assert.deepEqual(ended, { ok: true, value: 1 });
312+ assert.deepEqual(rows("SELECT id, status, error FROM deployments"), [
313+ { id: build.deploy_id, status: "failed", error: "The workflow job was cancelled before the build finished." },
314+ ]);
315+ // Another job's build, and one that is no longer open, are left alone.
316+ w.state.sqlite.prepare("UPDATE deployments SET job_id = 'job_2', status = 'building', error = NULL").run();
317+ assert.deepEqual(await post("/rpc/workflow_job_ended", { jobId: "job_1", conclusion: "success" }), { ok: true, value: 0 });
318+ assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "building" }]);
319+ w.state.sqlite.prepare("UPDATE deployments SET status = 'ready'").run();
320+ assert.deepEqual(await post("/rpc/workflow_job_ended", { jobId: "job_2", conclusion: "success" }), { ok: true, value: 0 });
321+ assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "ready" }]);
322+ assert.equal((await post("/rpc/workflow_job_ended", {})).error.code, "invalid");
323+});
+7−0
103103 assert.ok(!readOnlyAllowed(own, { kind: "pull", number: 4 }));
104104 assert.ok(!readOnlyAllowed(managed, { error: "a tag" }));
105105 });
106+
107+test("a build its job left open says how the job ended", async () => {
108+ const { jobEndedMessage } = await import("./workflow-builds.ts");
109+ assert.equal(jobEndedMessage("cancelled"), "The workflow job was cancelled before the build finished.");
110+ assert.equal(jobEndedMessage("failure"), "The workflow job failed before the build finished.");
111+ assert.equal(jobEndedMessage("success"), "The workflow job ended without finishing the build.");
112+});
+17−0
103103 }
104104
105105 /**
106+ * Why a build its workflow job left open failed, from how the job ended
107+ * (its conclusion: `cancelled`, `failure`, `success`, …).
108+ */
109+export function jobEndedMessage(conclusion: string): string {
110+ switch (conclusion) {
111+ case "cancelled":
112+ return "The workflow job was cancelled before the build finished.";
113+ case "failure":
114+ return "The workflow job failed before the build finished.";
115+ case "timed_out":
116+ return "The workflow job timed out before the build finished.";
117+ default:
118+ return "The workflow job ended without finishing the build.";
119+ }
120+}
121+
122+/**
106123 * The project to build: the one named, or the repository's one project
107124 * with Deployments on when none is named.
108125 */