Merge branch 'worktree-agent-ab6bf4195fcb97f7c' into claude/cool-newton-7nm8r9
19 files+587−580/19 viewed
| 70 | 70 | args["version"] = json!(body["version"].as_str().unwrap_or_default()); | |
| 71 | 71 | args["running"] = json!(body["running"].as_array().cloned().unwrap_or_default()); | |
| 72 | 72 | args["wait_ms"] = json!(body["wait_ms"].as_u64().unwrap_or(0)); | |
| 73 | + | args["isolation"] = json!(body["isolation"].as_str()); | |
| 73 | 74 | "runner_poll" | |
| 74 | 75 | } | |
| 75 | 76 | "finished" => { |
| 1492 | 1492 | is true when the commit before the push is not in the new commit's | |
| 1493 | 1493 | history. g1t reads the 20 latest commits to tell, so a forced push onto | |
| 1494 | 1494 | 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. | |
| 1496 | 1502 | - **On pull request events,** `pull_request.base.sha` is the tip of the | |
| 1497 | 1503 | branch it merges into when the event happened, and | |
| 1498 | 1504 | `pull_request.merge_commit_sha` is set only once it is merged, to the | |
| ⋯ | |||
| 1500 | 1506 | - **On `workflow_run`,** `workflow_run` has the followed run's `name`, | |
| 1501 | 1507 | `path`, `event`, `status`, `conclusion`, `head_branch`, `head_sha`, | |
| 1502 | 1508 | `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. | |
| 1505 | 1513 | ||
| 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. | |
| 1508 | 1518 | ||
| 1509 | 1519 | ## Checks | |
| 1510 | 1520 | ||
| 322 | 322 | uploads exactly as [What deploys](#what-deploys) says; its output is in | |
| 323 | 323 | the step's log and on the deployment's page. When the build fails, or | |
| 324 | 324 | 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**. | |
| 326 | 328 | ||
| 327 | 329 | - **Secrets.** A pull request from a fork or from someone without the | |
| 328 | 330 | Write role, and a run that had to be | |
| ⋯ | |||
| 673 | 675 | | 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. | | |
| 674 | 676 | | g1t/deploy says "Name the project to build" | The repository has several projects with Deployments on: give `project:`. | | |
| 675 | 677 | | 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. | | |
| 676 | 679 | | "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**. | | |
| 677 | 680 | ||
| 678 | 681 | ## Running your own g1t | |
| 144 | 144 | jobs directly on this machine (--no-docker), unsandboxed.* | |
| 145 | 145 | - Every job's log starts with a warning: *This job ran on the host, | |
| 146 | 146 | 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. | |
| 151 | 160 | ||
| 152 | 161 | Move a Linux runner to [the container](#run-it-as-a-container). A macOS | |
| 153 | 162 | or Windows runner stays in host mode until sandboxes for them are built. | |
| ⋯ | |||
| 550 | 559 | | `run` says *g1t no longer knows this runner* | It was removed, or its credential was. Register it again. | | |
| 551 | 560 | | *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). | | |
| 552 | 561 | | *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. | | |
| 554 | 564 | | 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`. | | |
| 555 | 565 | | *could not run docker* | Install Docker and start it, and give the runner's user access to it. | | |
| 556 | 566 | | Agent work fails at once | Give the runner `--agent-image`. | | |
| 361 | 361 | /// How the runner keeps work from its machine: `nested-container` (a | |
| 362 | 362 | /// container per job, inside the runner's own sandboxed container), | |
| 363 | 363 | /// `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. | |
| 366 | 367 | #[serde(default)] | |
| 367 | 368 | pub isolation: Option<String>, | |
| 368 | 369 | } | |
| ⋯ | |||
| 397 | 398 | pub running: Vec<String>, | |
| 398 | 399 | #[serde(default)] | |
| 399 | 400 | 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>, | |
| 400 | 406 | } | |
| 401 | 407 | ||
| 402 | 408 | /// Work for a runner. | |
| ⋯ | |||
| 614 | 620 | } | |
| 615 | 621 | } | |
| 616 | 622 | ||
| 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 | + | ||
| 617 | 636 | /// What a job waiting for a runner says. | |
| 618 | 637 | pub fn waiting_reason(wanted: &Wanted) -> String { | |
| 619 | 638 | format!("Waiting for a self-hosted runner with labels {}.", wanted.describe()) | |
| ⋯ | |||
| 628 | 647 | } | |
| 629 | 648 | ||
| 630 | 649 | #[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] | |
| 631 | 661 | fn a_runner_always_has_self_hosted_its_os_and_its_arch() { | |
| 632 | 662 | let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap(); | |
| 633 | 663 | assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"])); | |
| 113 | 113 | Ok(serde_json::from_value(answer)?) | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 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); | |
| 118 | 121 | let answer = post(&format!("{}/runners/{}/poll", self.base, self.runner), Some(&self.credential), &body)?; | |
| 119 | 122 | serde_json::from_value(answer).map_err(|e| Failure::Unreachable(e.to_string())) | |
| 120 | 123 | } | |
| ⋯ | |||
| 149 | 152 | std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)?; | |
| 150 | 153 | Ok(bytes) | |
| 151 | 154 | } | |
| 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 | + | } | |
| 499 | 499 | } | |
| 500 | 500 | let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect(); | |
| 501 | 501 | 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()) { | |
| 503 | 503 | Ok(poll) => { | |
| 504 | 504 | backoff = Duration::from_secs(2); | |
| 505 | 505 | poll |
| 13 | 13 | use g1t_actions::events::{account_id, github_id}; | |
| 14 | 14 | use g1t_contracts::{PrincipalKind, User}; | |
| 15 | 15 | 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}; | |
| 17 | 18 | use serde_json::{Value, json}; | |
| 18 | 19 | ||
| 19 | 20 | use crate::{API, SITE}; | |
| ⋯ | |||
| 119 | 120 | Value::Array(names.iter().map(|name| label(repo, name, None)).collect()) | |
| 120 | 121 | } | |
| 121 | 122 | ||
| 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. | |
| 123 | 126 | 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, | |
| 127 | 165 | } | |
| 128 | − | label | |
| 129 | 166 | } | |
| 130 | 167 | ||
| 131 | 168 | /// A milestone as an issue or a pull request names it. | |
| ⋯ | |||
| 158 | 195 | }) | |
| 159 | 196 | } | |
| 160 | 197 | ||
| 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 | + | ||
| 161 | 217 | /// A push. `forced` is whether `before` is not in `after`'s history, when | |
| 162 | 218 | /// 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 { | |
| 164 | 232 | let before = before.unwrap_or(ZERO); | |
| 165 | 233 | let created = before == ZERO; | |
| 166 | 234 | let deleted = after == ZERO; | |
| ⋯ | |||
| 183 | 251 | // GitHub's head commit is the one pushed: the last. | |
| 184 | 252 | "head_commit": commits.last().cloned().unwrap_or(Value::Null), | |
| 185 | 253 | "commits": commits, | |
| 186 | − | "pusher": { "name": pusher, "email": null }, | |
| 254 | + | "pusher": { "name": pusher, "email": pusher_email }, | |
| 187 | 255 | "repository": repository(repo), | |
| 188 | 256 | "sender": user(pusher), | |
| 189 | 257 | }) | |
| ⋯ | |||
| 420 | 488 | pub triggering_actor: Option<&'a str>, | |
| 421 | 489 | pub title: Option<&'a str>, | |
| 422 | 490 | 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>, | |
| 423 | 495 | } | |
| 424 | 496 | ||
| 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 | + | ||
| 425 | 524 | pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value { | |
| 426 | 525 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 427 | 526 | let data = run.data; | |
| ⋯ | |||
| 463 | 562 | "logs_url": format!("{url}/logs"), | |
| 464 | 563 | "artifacts_url": format!("{url}/artifacts"), | |
| 465 | 564 | "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), | |
| 470 | 566 | "repository": repository(repo), | |
| 471 | 567 | "head_repository": repository(repo), | |
| 472 | 568 | }) | |
| ⋯ | |||
| 586 | 682 | ("workflow_run", "workflow_run.id", "string"), | |
| 587 | 683 | ("workflow_run", "workflow_run.workflow_id", "string"), | |
| 588 | 684 | ("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"), | |
| 595 | 685 | ]; | |
| 596 | 686 | ||
| 597 | 687 | fn kind(value: &Value) -> &'static str { | |
| ⋯ | |||
| 705 | 795 | fn payloads() -> Vec<(&'static str, Value)> { | |
| 706 | 796 | let repo = repo(); | |
| 707 | 797 | 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!({ | |
| 710 | 802 | "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull), | |
| 711 | 803 | "repository": repository(&repo), "sender": user("ada"), | |
| 712 | 804 | }); | |
| 805 | + | color_labels(&mut pull_request, &known); | |
| 713 | 806 | let review = json!({ | |
| 714 | 807 | "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))), | |
| 715 | 808 | "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"), | |
| 716 | 809 | }); | |
| 717 | 810 | let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") }); | |
| 718 | 811 | changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } })); | |
| 812 | + | color_labels(&mut issues, &known); | |
| 719 | 813 | let mut on_pull = pull.clone(); | |
| 720 | 814 | on_pull.labels.clear(); | |
| 721 | 815 | on_pull.assignees.clear(); | |
| ⋯ | |||
| 742 | 836 | triggering_actor: None, | |
| 743 | 837 | title: Some("Update README.md"), | |
| 744 | 838 | head_commit: Some(&head), | |
| 839 | + | pull: Some(&pull), | |
| 840 | + | base_sha: Some("f95f852bd8fca8fcc58a9a2d6c842781e32a215e"), | |
| 745 | 841 | }; | |
| 746 | 842 | // As trigger.rs gives it. | |
| 747 | 843 | let workflow_run = json!({ | |
| ⋯ | |||
| 845 | 941 | fn a_push_says_whether_it_made_deleted_or_forced_its_ref() { | |
| 846 | 942 | let repo = repo(); | |
| 847 | 943 | 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); | |
| 849 | 945 | assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false))); | |
| 850 | 946 | 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); | |
| 852 | 948 | assert_eq!(forced["forced"], true); | |
| 853 | 949 | assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}")); | |
| 854 | 950 | 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); | |
| 856 | 952 | assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false))); | |
| 857 | 953 | } | |
| 858 | 954 | ||
| 859 | 955 | #[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] | |
| 860 | 1019 | fn a_pull_request_from_a_fork_names_the_fork() { | |
| 861 | 1020 | let mut pull = a_pull(); | |
| 862 | 1021 | pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() }); | |
| 1680 | 1680 | let Some(job) = finished else { return Ok(()) }; | |
| 1681 | 1681 | // Its G1T_TOKEN stops working with it. | |
| 1682 | 1682 | 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 | + | } | |
| 1683 | 1688 | // A job that deploys failed: so did its run's deployment, now. | |
| 1684 | 1689 | if conclusion == "failure" && job.continue_on_error == 0 && job.started_at.is_some() | |
| 1685 | 1690 | && let Some(run) = self.run_row(&job.run_id).await? | |
| ⋯ | |||
| 1774 | 1779 | .await?; | |
| 1775 | 1780 | if stopped.as_ref().is_some_and(|row| row.started_at.is_some()) { | |
| 1776 | 1781 | self.revoke_job_tokens(&job.id).await; | |
| 1782 | + | self.end_job_builds(&job.id, "cancelled").await; | |
| 1777 | 1783 | } | |
| 1778 | 1784 | if let Some(stopped) = stopped.filter(|row| row.runner_id.is_some()) { | |
| 1779 | 1785 | self.released(&stopped).await?; | |
| ⋯ | |||
| 1871 | 1877 | Ok(()) | |
| 1872 | 1878 | } | |
| 1873 | 1879 | ||
| 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 | + | ||
| 1874 | 1892 | /// Tells the deployments service how a run's deployment to `env` stands: | |
| 1875 | 1893 | /// made when its first job naming the environment starts, failed when one | |
| 1876 | 1894 | /// of them fails, and settled (`last`) when the run finishes. Never fails | |
| 32 | 32 | use crate::plan::JobRow; | |
| 33 | 33 | use crate::{Actions, Count, SILENT_MS, check, fail, optional}; | |
| 34 | 34 | ||
| 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 | + | ||
| 35 | 44 | /// How often a poll looks again for work while it waits. | |
| 36 | 45 | const POLL_EVERY_MS: u64 = 2_500; | |
| 37 | 46 | /// A runner that has been offline this long is removed, as on GitHub. | |
| ⋯ | |||
| 69 | 78 | pub last_seen_at: Option<String>, | |
| 70 | 79 | pub created_at: String, | |
| 71 | 80 | 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>, | |
| 72 | 85 | } | |
| 73 | 86 | ||
| 74 | 87 | impl RunnerRow { | |
| ⋯ | |||
| 953 | 966 | let version: String = if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() }; | |
| 954 | 967 | let work_id = active.as_ref().map(|(id, _)| id.as_str()); | |
| 955 | 968 | 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; | |
| 957 | 981 | if changed || seen_is_stale(row.last_seen_at.as_deref(), at) { | |
| 958 | 982 | 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 | + | ])? | |
| 961 | 992 | .run() | |
| 962 | 993 | .await?; | |
| 963 | 994 | } | |
| 995 | + | let row = RunnerRow { isolation, ..row }; | |
| 964 | 996 | let mut poll = Poll { assignment: None, cancel, credential, removed: false }; | |
| 965 | 997 | // Busy, or an ephemeral runner that has had its one job. | |
| 966 | 998 | if active.is_some() || !a.running.is_empty() { | |
| ⋯ | |||
| 974 | 1006 | let groups = self.group_rows(&row.workspace).await?; | |
| 975 | 1007 | let group = row.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id)).cloned(); | |
| 976 | 1008 | let deadline = at + a.wait_ms.min(model::MAX_POLL_WAIT_MS); | |
| 1009 | + | let mut looked = Looked::default(); | |
| 977 | 1010 | 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? { | |
| 979 | 1012 | poll.assignment = Some(assignment); | |
| 980 | 1013 | return Ok(Outcome::Ok(poll)); | |
| 981 | 1014 | } | |
| ⋯ | |||
| 987 | 1020 | } | |
| 988 | 1021 | ||
| 989 | 1022 | /// 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>> { | |
| 991 | 1024 | let labels = runner.labels(); | |
| 992 | 1025 | let group_name = group.map(|g| g.name.clone()); | |
| 993 | 1026 | // Workflow jobs. | |
| ⋯ | |||
| 1021 | 1054 | if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&run_repo)) { | |
| 1022 | 1055 | continue; | |
| 1023 | 1056 | } | |
| 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 | + | } | |
| 1024 | 1068 | if let Some(max) = job.max_parallel { | |
| 1025 | 1069 | let siblings = self | |
| 1026 | 1070 | .db | |
| ⋯ | |||
| 1103 | 1147 | if !wanted.matches(&labels, None) { | |
| 1104 | 1148 | continue; | |
| 1105 | 1149 | } | |
| 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 | + | } | |
| 1106 | 1159 | if let Some(repo_id) = &runner.repo_id | |
| 1107 | 1160 | && task.repo_id.as_deref() != Some(repo_id.as_str()) | |
| 1108 | 1161 | { | |
| ⋯ | |||
| 1168 | 1221 | Ok(None) | |
| 1169 | 1222 | } | |
| 1170 | 1223 | ||
| 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 | + | ||
| 1171 | 1254 | /// A repository's visibility for a runner, `public` or `private`; none | |
| 1172 | 1255 | /// when it cannot be read, and the runner decides without it. | |
| 1173 | 1256 | async fn visibility(&self, repo_id: &str) -> Option<String> { | |
| ⋯ | |||
| 1605 | 1688 | id: String, | |
| 1606 | 1689 | } | |
| 1607 | 1690 | ||
| 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 | + | ||
| 1608 | 1702 | fn group_view(group: &GroupRow, runners: &[RunnerRow]) -> RunnerGroup { | |
| 1609 | 1703 | RunnerGroup { | |
| 1610 | 1704 | id: group.id.clone(), | |
| ⋯ | |||
| 1661 | 1755 | } | |
| 1662 | 1756 | ||
| 1663 | 1757 | #[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] | |
| 1664 | 1795 | fn online_means_seen_in_the_last_ninety_seconds() { | |
| 1665 | 1796 | let row = RunnerRow { | |
| 1666 | 1797 | id: "rnr_1".into(), | |
| ⋯ | |||
| 1683 | 1814 | last_seen_at: Some(rfc3339(1_000_000_000)), | |
| 1684 | 1815 | created_at: String::new(), | |
| 1685 | 1816 | created_by: None, | |
| 1817 | + | isolation: None, | |
| 1686 | 1818 | }; | |
| 1687 | 1819 | assert!(row.online(1_000_000_000 + 60_000)); | |
| 1688 | 1820 | assert!(!row.online(1_000_000_000 + 120_000)); | |
| 8 | 8 | use g1t_contracts::actions::{DispatchArgs, RepositoryDispatchArgs, WorkflowRun}; | |
| 9 | 9 | use g1t_contracts::events::{Event, caused_by_job}; | |
| 10 | 10 | 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}; | |
| 13 | 13 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 14 | 14 | use g1t_contracts::time::rfc3339; | |
| 15 | 15 | use g1t_kit::now_ms; | |
| ⋯ | |||
| 53 | 53 | merge: Option<String>, | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 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 | + | ||
| 56 | 76 | /// Whether a deployment's `ref` is a commit's full hash rather than a | |
| 57 | 77 | /// branch or tag. | |
| 58 | 78 | fn is_commit(name: &str) -> bool { | |
| ⋯ | |||
| 206 | 226 | Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next())) | |
| 207 | 227 | } | |
| 208 | 228 | ||
| 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)>> { | |
| 210 | 231 | let compared: Outcome<Comparison> = g1t_kit::call( | |
| 211 | 232 | &self.repos, | |
| 212 | 233 | "compare", | |
| ⋯ | |||
| 219 | 240 | }, | |
| 220 | 241 | ) | |
| 221 | 242 | .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()) | |
| 223 | 244 | } | |
| 224 | 245 | ||
| 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 | + | ||
| 225 | 259 | async fn default_head(&self, repo: &Repo) -> Result<Option<String>> { | |
| 226 | 260 | self.branch_head(repo, &repo.default_branch).await | |
| 227 | 261 | } | |
| ⋯ | |||
| 318 | 352 | let before = data["before"].as_str(); | |
| 319 | 353 | let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?; | |
| 320 | 354 | 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); | |
| 322 | 357 | Some(Subject { | |
| 323 | 358 | source: path.clone(), | |
| 324 | 359 | source_ref: Some(after.to_owned()), | |
| ⋯ | |||
| 408 | 443 | "sender": payload::user(sender), | |
| 409 | 444 | }); | |
| 410 | 445 | payload::changed(repo, &mut payload, data); | |
| 446 | + | self.label_colors(repo, ws, &mut payload).await?; | |
| 411 | 447 | // `base.sha` is the base branch's tip now, as GitHub gives | |
| 412 | 448 | // it, not where the two last agreed. | |
| 413 | 449 | if let Some(tip) = self.branch_head(repo, &base_ref).await? { | |
| ⋯ | |||
| 521 | 557 | Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head), | |
| 522 | 558 | None => None, | |
| 523 | 559 | }; | |
| 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 | + | }; | |
| 524 | 572 | let followed = payload::FollowedRun { | |
| 573 | + | pull: pull.as_ref(), | |
| 574 | + | base_sha: base_sha.as_deref(), | |
| 525 | 575 | data, | |
| 526 | 576 | workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()), | |
| 527 | 577 | attempt: row.as_ref().map(|r| r.attempt), | |
| ⋯ | |||
| 574 | 624 | "sender": payload::user(sender), | |
| 575 | 625 | }); | |
| 576 | 626 | payload::changed(repo, &mut payload, data); | |
| 627 | + | self.label_colors(repo, ws, &mut payload).await?; | |
| 577 | 628 | if event_name == "issue_comment" { | |
| 578 | 629 | let comment_id = data["commentId"].as_str(); | |
| 579 | 630 | if action == Some("deleted") { | |
| ⋯ | |||
| 764 | 815 | if copied_in && matches!(event_name, "push" | "create") { | |
| 765 | 816 | if subject.paths.is_none() { | |
| 766 | 817 | 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?); | |
| 768 | 819 | } | |
| 769 | 820 | if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default()) | |
| 770 | 821 | && let Some(mirror) = &repo.mirror | |
| ⋯ | |||
| 818 | 869 | actor_id: Option<&str>, | |
| 819 | 870 | sender: &str, | |
| 820 | 871 | ) -> 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 { | |
| 823 | 883 | let workflow = match parsed { | |
| 824 | 884 | Ok(workflow) => workflow, | |
| 825 | 885 | Err(problem) => { | |
| ⋯ | |||
| 904 | 964 | if trigger.paths.is_set() { | |
| 905 | 965 | if subject.paths.is_none() { | |
| 906 | 966 | 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?); | |
| 908 | 968 | } | |
| 909 | 969 | if !trigger.paths.allows_paths(subject.paths.as_deref().unwrap_or_default()) { | |
| 910 | 970 | return Ok(false); | |
| ⋯ | |||
| 1453 | 1513 | use g1t_contracts::{Membership, PrincipalKind}; | |
| 1454 | 1514 | ||
| 1455 | 1515 | #[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] | |
| 1456 | 1524 | fn a_pull_request_builds_its_merge_commit_and_nothing_while_it_conflicts() { | |
| 1457 | 1525 | let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() }; | |
| 1458 | 1526 | let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] }; | |
| 9 | 9 | "state": "open", | |
| 10 | 10 | "state_reason": null, | |
| 11 | 11 | "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 }], | |
| 13 | 13 | "milestone": { "id": 4317517, "number": 1, "title": "v1.0", "html_url": "https://github.com/acme/web/milestone/1" }, | |
| 14 | 14 | "user": { "login": "ada", "id": 21031068, "node_id": "MDQ6VXNlcjIxMDMxMDY4", "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" }, | |
| 15 | 15 | "assignee": { "login": "bo", "id": 21031069, "type": "User", "html_url": "https://github.com/bo", "url": "https://api.github.com/users/bo" }, | |
| ⋯ | |||
| 22 | 22 | "url": "https://api.github.com/repos/acme/web/issues/1", | |
| 23 | 23 | "repository_url": "https://api.github.com/repos/acme/web" | |
| 24 | 24 | }, | |
| 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 }, | |
| 26 | 26 | "repository": { "id": 186853002, "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", "full_name": "acme/web", "private": false, "owner": { "login": "acme", "id": 21031067, "type": "Organization" } }, | |
| 27 | 27 | "sender": { "login": "ada", "id": 21031068, "type": "User" } | |
| 28 | 28 | } | |
| 15 | 15 | "merged_by": null, | |
| 16 | 16 | "merge_commit_sha": null, | |
| 17 | 17 | "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 } | |
| 19 | 19 | ], | |
| 20 | 20 | "milestone": { | |
| 21 | 21 | "id": 4317517, |
| 34 | 34 | "artifacts_url": "https://api.github.com/repos/acme/web/actions/runs/30433642/artifacts", | |
| 35 | 35 | "workflow_url": "https://api.github.com/repos/acme/web/actions/workflows/159038", | |
| 36 | 36 | "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 | + | } | |
| 38 | 52 | ], | |
| 39 | 53 | "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } }, | |
| 40 | 54 | "head_repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } } |
| 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; |
| 91 | 91 | import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries"; | |
| 92 | 92 | import { appHost, appUrl, label, uniqueLabel } from "./names"; | |
| 93 | 93 | 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"; | |
| 95 | 95 | ||
| 96 | 96 | type Env = { | |
| 97 | 97 | DB: D1Database; | |
| ⋯ | |||
| 1030 | 1030 | }); | |
| 1031 | 1031 | } | |
| 1032 | 1032 | ||
| 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 | + | ||
| 1033 | 1050 | private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> { | |
| 1034 | 1051 | const settings = await this.settingsRow(project.id); | |
| 1035 | 1052 | if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null; | |
| ⋯ | |||
| 2429 | 2446 | // For the API: a g1t Actions job opening a build it runs itself. | |
| 2430 | 2447 | case "start_from_workflow": | |
| 2431 | 2448 | 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); | |
| 2432 | 2452 | case "take_down": | |
| 2433 | 2453 | return service.takeDown(args); | |
| 2434 | 2454 | case "overview": | |
| 304 | 304 | assert.equal(await refused({}), "conflict"); | |
| 305 | 305 | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); | |
| 306 | 306 | }); | |
| 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 | + | }); |
| 103 | 103 | assert.ok(!readOnlyAllowed(own, { kind: "pull", number: 4 })); | |
| 104 | 104 | assert.ok(!readOnlyAllowed(managed, { error: "a tag" })); | |
| 105 | 105 | }); | |
| 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 | + | }); |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | /** | |
| 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 | + | /** | |
| 106 | 123 | * The project to build: the one named, or the repository's one project | |
| 107 | 124 | * with Deployments on when none is named. | |
| 108 | 125 | */ |