Deleting a branch or tag with a push now starts `on: delete` workflows, as on GitHub: they run on the default branch's latest commit, with `github.ref` the default branch and `github.event` naming the `ref`, its `ref_type`, `pusher_type`, the repository and the sender. Repos says so with a new `git.deleted` event, once the store has let the ref go, which webhooks can hear too. Neither `create` nor `delete` applies `branches` or `tags` filters any more, since GitHub ignores them there. A manual run refuses an input its workflow does not declare, naming the unexpected inputs instead of dropping them; a boolean input must be true or false, a whole number stays whole, and an `environment` input must name one of the repository's environments. `github.event.inputs` now holds every input as text, as GitHub gives it, while the `inputs` context keeps booleans and numbers. An `assigned` workflow no longer starts when people were only taken off an issue, and `github.event.assignee` names who was assigned. A workflow on `on: label` is told g1t never sends it. The actions, limitations and webhooks guides and the dispatch API's description say so.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
17 files+363−390/17 viewed
| 2729 | 2729 | "repo": repo_schema(), | |
| 2730 | 2730 | "workflow": { "type": "string", "description": "The workflow's id or file name." }, | |
| 2731 | 2731 | "ref": { "type": "string", "description": "A branch or tag. The default branch if not given." }, | |
| 2732 | − | "inputs": { "type": "object", "description": "The workflow_dispatch inputs, by name." }, | |
| 2732 | + | "inputs": { "type": "object", "description": "The workflow_dispatch inputs, by name. Only inputs the workflow declares: any other is refused. A boolean is true or false, a number a number, a choice one of its options, and an environment one of the repository's." }, | |
| 2733 | 2733 | }), | |
| 2734 | 2734 | &["repo", "workflow"], | |
| 2735 | 2735 | ), |
| 279 | 279 | entry under 128 MB in one request, and g1t takes at most 100 MB in one | |
| 280 | 280 | request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and | |
| 281 | 281 | the job goes on; smaller and larger entries are saved. | |
| 282 | − | - `on: delete`: deleting a branch or tag starts no workflows. New branches | |
| 283 | − | and tags start `create` and `push` workflows. | |
| 282 | + | - `on: label`: creating, editing or deleting a label starts no workflows. | |
| 283 | + | Putting a label on an issue or a pull request starts `issues` and | |
| 284 | + | `pull_request` workflows with the `labeled` type. | |
| 285 | + | - The `pull_request` activity types `unassigned`, `locked`, `unlocked`, | |
| 286 | + | and `edited` for a new title or description, and the `issues` types | |
| 287 | + | `unassigned`, `locked`, `unlocked`, `pinned` and `unpinned`: g1t starts | |
| 288 | + | no workflows for them. | |
| 284 | 289 | ||
| 285 | 290 | See [Not yet](/guides/actions/#not-yet). **Status.** Planned. | |
| 286 | 291 |
| 49 | 49 | ||
| 50 | 50 | | On GitHub | On g1t | | |
| 51 | 51 | | --- | --- | | |
| 52 | − | | `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch`, `workflow_run`, `merge_group`, `create`, `repository_dispatch`, `release`, `deployment`, `deployment_status` | The same, from g1t's own pushes, pull requests, issues, comments, [releases](#releases), [deployments](#deployments) and [merge queue](/guides/merge-queue/). `create` starts on each new branch or tag; `repository_dispatch` on [a dispatch event](#repository-dispatch). | | |
| 52 | + | | `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch`, `workflow_run`, `merge_group`, `create`, `delete`, `repository_dispatch`, `release`, `deployment`, `deployment_status` | The same, from g1t's own pushes, pull requests, issues, comments, [releases](#releases), [deployments](#deployments) and [merge queue](/guides/merge-queue/). `create` starts on each new branch or tag, at its commit; `delete` on each branch or tag a push deletes, on the default branch's latest commit, with `github.event.ref` and `github.event.ref_type` naming what went. As on GitHub, neither takes `branches` or `tags` filters: a workflow on them runs for every branch and tag. `repository_dispatch` starts on [a dispatch event](#repository-dispatch). | | |
| 53 | 53 | | `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. | | |
| 54 | 54 | | `timeout-minutes` and `continue-on-error` on a step | The same, for `run:` and `uses:` steps alike. A `uses:` step's action is stopped at its limit, with every process it started; a step inside a composite action stops at its own limit or the `uses:` step's, whichever comes first. A step stopped this way fails, unless `continue-on-error` lets the job go on. | | |
| 55 | 55 | | `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. | | |
| ⋯ | |||
| 93 | 93 | github.com. `actions/upload-artifact`, `actions/download-artifact` and | |
| 94 | 94 | `actions/upload-artifact/merge` work, because g1t runs them itself. See | |
| 95 | 95 | [actions built on the toolkit](#actions-built-on-the-toolkit). | |
| 96 | − | - **`on: delete`.** Deleting a branch or tag starts no workflows yet; the | |
| 97 | − | workflow's page says so. | |
| 96 | + | - **`on: label`, and some activity types.** Creating, editing or deleting | |
| 97 | + | a label starts no workflows yet, and the workflow's page says so. Nor do | |
| 98 | + | the `pull_request` types `unassigned`, `locked` and `unlocked`, `edited` | |
| 99 | + | for a new title or description, or the `issues` types `unassigned`, | |
| 100 | + | `locked`, `unlocked`, `pinned` and `unpinned`. | |
| 98 | 101 | ||
| 99 | 102 | Why each of these is missing, and what to use instead, is on | |
| 100 | 103 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| ⋯ | |||
| 1099 | 1102 | `ready_for_review`, `labeled`, `unlabeled`, `milestoned`, `demilestoned`, | |
| 1100 | 1103 | `assigned`, `review_requested` and `closed`, and `edited` when the branch | |
| 1101 | 1104 | a pull request merges into changes; `issue_comment` workflows on | |
| 1102 | − | `created`, `edited` (with `github.event.changes.body.from`) and `deleted`; `issues` workflows on `labeled`, `unlabeled`, `milestoned` and | |
| 1105 | + | `created`, `edited` (with `github.event.changes.body.from`) and `deleted`; `issues` workflows on `assigned`, `labeled`, `unlabeled`, `milestoned` and | |
| 1103 | 1106 | `demilestoned` too. List them under `types:` to run on them. For | |
| 1104 | − | `labeled` and `unlabeled`, `github.event.label` names the label. A pull | |
| 1107 | + | `labeled` and `unlabeled`, `github.event.label` names the label; for | |
| 1108 | + | `assigned`, `github.event.assignee` names who was assigned. Assigning | |
| 1109 | + | several people at once starts one run, which names the first of them. A pull | |
| 1105 | 1110 | request's `branches` filter, `github.base_ref` and | |
| 1106 | 1111 | `pull_request.base.ref` are the branch it merges into, which is not | |
| 1107 | 1112 | always the default branch: see | |
| ⋯ | |||
| 1494 | 1499 | -d '{"ref": "main", "inputs": {"environment": "staging"}}' | |
| 1495 | 1500 | ``` | |
| 1496 | 1501 | ||
| 1502 | + | Give only the inputs the workflow declares under | |
| 1503 | + | `on.workflow_dispatch.inputs`: a run with any other input is refused, and | |
| 1504 | + | the answer names the inputs it did not expect. A `boolean` input is `true` | |
| 1505 | + | or `false`, a `number` a number, a `choice` one of its `options`, and an | |
| 1506 | + | `environment` the name of one of the repository's | |
| 1507 | + | [environments](#environments). In the run, the `inputs` context keeps | |
| 1508 | + | booleans and numbers as they are, and `github.event.inputs` has every | |
| 1509 | + | input as text, `"true"` and `"3"`, as on GitHub. | |
| 1510 | + | ||
| 1497 | 1511 | To save an artifact from a script, follow the redirect: | |
| 1498 | 1512 | ||
| 1499 | 1513 | ```sh | |
| 73 | 73 | | `repo.renamed` | It was given a new name. Its old address redirects. | | |
| 74 | 74 | | `repo.transferred` | It moved to another workspace. | | |
| 75 | 75 | | `repo.default_branch_changed` | Its default branch changed, or the default branch was renamed. | | |
| 76 | + | | `git.deleted` | A push deleted a branch or tag. `data.ref` is the full ref, and `data.before` the commit it pointed to. A push that deletes more than 3 tags sends none for them. | | |
| 76 | 77 | | `branch.renamed` | A branch was renamed. | | |
| 77 | 78 | | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had their role changed, or lost it. `data.username`, `data.role`, `data.previous_role`. See [access and roles](/guides/access-and-roles/). | | |
| 78 | 79 | | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. Sent to the workspace's webhooks. `data.workspace`, `data.team` (its slug), `data.team_id`, `data.name`, `data.visibility`, `data.parent`; on `team.edited`, `data.changes`. | |
Binary or large file; its contents are not shown.
| 6 | 6 | ||
| 7 | 7 | /// The GitHub event and activity type for a g1t event, if it has one. | |
| 8 | 8 | /// A g1t event can be more than one GitHub event: a pull request opening | |
| 9 | − | /// is `pull_request` and `pull_request_target`. | |
| 9 | + | /// is `pull_request` and `pull_request_target`. A new branch or tag is | |
| 10 | + | /// `push` and `create`; services/actions adds `create` for a `git.push` | |
| 11 | + | /// with no `before`. | |
| 10 | 12 | pub fn github_events(kind: &str) -> Vec<(&'static str, Option<&'static str>)> { | |
| 11 | 13 | let pull = |action| vec![("pull_request", Some(action)), ("pull_request_target", Some(action))]; | |
| 12 | 14 | match kind { | |
| 13 | 15 | "git.push" => vec![("push", None)], | |
| 16 | + | "git.deleted" => vec![("delete", None)], | |
| 14 | 17 | "pull.opened" => pull("opened"), | |
| 15 | 18 | "pull.updated" => pull("synchronize"), | |
| 16 | 19 | "pull.ready" => pull("ready_for_review"), | |
| ⋯ | |||
| 215 | 218 | #[test] | |
| 216 | 219 | fn g1t_events_are_github_events() { | |
| 217 | 220 | assert_eq!(github_events("git.push"), [("push", None)]); | |
| 221 | + | assert_eq!(github_events("git.deleted"), [("delete", None)]); | |
| 222 | + | assert_eq!(github_events("pull.labeled")[1], ("pull_request_target", Some("labeled"))); | |
| 223 | + | assert_eq!(github_events("pull.assigned")[0], ("pull_request", Some("assigned"))); | |
| 224 | + | assert_eq!(github_events("issue.unlabeled"), [("issues", Some("unlabeled"))]); | |
| 225 | + | assert_eq!(github_events("issue.assigned"), [("issues", Some("assigned"))]); | |
| 218 | 226 | assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize"))); | |
| 219 | 227 | assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed"))); | |
| 220 | 228 | assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]); | |
| 30 | 30 | "workflow_run", | |
| 31 | 31 | "merge_group", | |
| 32 | 32 | "create", | |
| 33 | + | "delete", | |
| 33 | 34 | "release", | |
| 34 | 35 | "deployment", | |
| 35 | 36 | "deployment_status", | |
| ⋯ | |||
| 38 | 39 | /// Events GitHub has that g1t knows of but never sends: a workflow on one | |
| 39 | 40 | /// of them is told so, rather than waiting for a run that never comes. | |
| 40 | 41 | pub const UNSENT_EVENTS: &[(&str, &str)] = &[( | |
| 41 | − | "delete", | |
| 42 | − | "g1t does not start runs when a branch or tag is deleted yet, so the `delete` trigger never starts it. New branches and tags start `create` and `push` workflows.", | |
| 42 | + | "label", | |
| 43 | + | "g1t does not start runs when a label is created, edited or deleted yet, so the `label` trigger never starts it. Putting a label on an issue or a pull request starts `issues` and `pull_request` workflows with the `labeled` type.", | |
| 43 | 44 | )]; | |
| 44 | 45 | ||
| 45 | 46 | /// The `types` each event has when a workflow gives none, as on GitHub. | |
| ⋯ | |||
| 769 | 770 | ||
| 770 | 771 | #[test] | |
| 771 | 772 | fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() { | |
| 772 | − | let workflow = parse("on: [create, delete, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap(); | |
| 773 | + | let workflow = parse("on: [create, delete, label, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap(); | |
| 773 | 774 | let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect(); | |
| 774 | 775 | assert_eq!(unsupported.len(), 1, "{unsupported:?}"); | |
| 775 | − | assert!(unsupported[0].contains("`delete`")); | |
| 776 | + | assert!(unsupported[0].contains("`label`")); | |
| 776 | 777 | let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap(); | |
| 777 | 778 | assert!(target.message.contains("default branch")); | |
| 778 | 779 | assert!(!target.message.contains("on the pull request's head")); | |
| 129 | 129 | pub commits: Option<u32>, | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | + | /// A branch or tag deleted by a push. `before` is the commit it pointed to. | |
| 133 | + | #[derive(Debug, Serialize)] | |
| 134 | + | #[serde(rename_all = "camelCase")] | |
| 135 | + | pub struct GitDeleted { | |
| 136 | + | pub repo_id: String, | |
| 137 | + | /// The full ref, such as `refs/heads/feature`. | |
| 138 | + | #[serde(rename = "ref")] | |
| 139 | + | pub git_ref: String, | |
| 140 | + | pub before: String, | |
| 141 | + | /// Set when a workflow job's token deleted it: the run's id (see | |
| 142 | + | /// [`CAUSED_BY_JOB`]). Absent otherwise. | |
| 143 | + | #[serde(rename = "causedByJob", skip_serializing_if = "Option::is_none")] | |
| 144 | + | pub caused_by_job: Option<String>, | |
| 145 | + | } | |
| 146 | + | ||
| 132 | 147 | /// The payload of `issue.opened`, `issue.updated`, `issue.assigned`, | |
| 133 | 148 | /// `issue.labeled`, `issue.unlabeled`, `issue.milestoned`, | |
| 134 | 149 | /// `issue.demilestoned`, `issue.closed` and `issue.reopened`; each uses |
| 75 | 75 | "SUBSCRIBER_ACTIONS", | |
| 76 | 76 | &[ | |
| 77 | 77 | "git.push", | |
| 78 | + | "git.deleted", | |
| 78 | 79 | "pull.*", | |
| 79 | 80 | "issue.*", | |
| 80 | 81 | "comment.*", |
| 15 | 15 | use crate::{User, Viewer}; | |
| 16 | 16 | ||
| 17 | 17 | /// Every event a webhook can be sent, in the order people are shown them. | |
| 18 | − | pub const EVENT_TYPES: [&str; 106] = [ | |
| 18 | + | pub const EVENT_TYPES: [&str; 107] = [ | |
| 19 | 19 | "git.push", | |
| 20 | + | "git.deleted", | |
| 20 | 21 | "branch.renamed", | |
| 21 | 22 | "repo.created", | |
| 22 | 23 | "repo.forked", |
| 224 | 224 | commits?: number; | |
| 225 | 225 | }; | |
| 226 | 226 | /** | |
| 227 | + | * A branch or tag was deleted by a push. `before` is the commit it pointed | |
| 228 | + | * to. `causedByJob` is set when a workflow job's token deleted it: the run's id. | |
| 229 | + | */ | |
| 230 | + | "git.deleted": { repoId: string; ref: string; before: string; causedByJob?: string }; | |
| 231 | + | /** | |
| 227 | 232 | * `author` is who opened it: g1t, for one its agent filed while at work, | |
| 228 | 233 | * with `requestedBy` the person it was working for. Every issue and pull | |
| 229 | 234 | * request event carries both. |
| 10 | 10 | /** Every event a webhook can be sent, in the order people are shown them. */ | |
| 11 | 11 | export const EVENT_TYPES = [ | |
| 12 | 12 | "git.push", | |
| 13 | + | "git.deleted", | |
| 13 | 14 | "branch.renamed", | |
| 14 | 15 | "repo.created", | |
| 15 | 16 | "repo.forked", |
| 153 | 153 | }) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | − | /// What a `labeled`, `unlabeled`, `milestoned`, `demilestoned` or base | |
| 157 | − | /// change was about, from the g1t event: `label`, `milestone`, and for a | |
| 158 | − | /// new base, `changes.base.ref.from` is not known, so only the new base is | |
| 159 | − | /// in `pull_request.base`. | |
| 156 | + | /// What a `labeled`, `unlabeled`, `milestoned`, `demilestoned`, | |
| 157 | + | /// `assigned` or base change was about, from the g1t event: `label`, | |
| 158 | + | /// `milestone`, `assignee` (the first of those newly assigned; g1t sends | |
| 159 | + | /// one event for them all, GitHub one each), and for a new base, | |
| 160 | + | /// `changes.base.ref.from` is not known, so only the new base is in | |
| 161 | + | /// `pull_request.base`. | |
| 160 | 162 | pub fn changed(payload: &mut Value, data: &Value) { | |
| 163 | + | if let Some(first) = data["added"].as_array().and_then(|added| added.first()).and_then(Value::as_str) { | |
| 164 | + | payload["assignee"] = user(first); | |
| 165 | + | } | |
| 161 | 166 | if let Some(label) = data.get("label").filter(|label| label.is_object()) { | |
| 162 | 167 | payload["label"] = json!({ "name": label["name"], "color": label["color"] }); | |
| 163 | 168 | } |
| 498 | 498 | let name = check!(environment_name(name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok)); | |
| 499 | 499 | return Ok(Outcome::Ok(vec![self.environment_rules(&repo.id, &name).await?])); | |
| 500 | 500 | } | |
| 501 | + | Ok(Outcome::Ok(self.environment_list(&repo.id).await?)) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | /// Every environment a repository has: those with rules, and those its | |
| 505 | + | /// secrets, workflows or jobs name. Protected ones first. | |
| 506 | + | pub(crate) async fn environment_list(&self, repo_id: &str) -> Result<Vec<Environment>> { | |
| 501 | 507 | let rows = self | |
| 502 | 508 | .db | |
| 503 | 509 | .prepare("SELECT * FROM environments WHERE repo_id = ? ORDER BY name") | |
| 504 | − | .bind(&[repo.id.as_str().into()])? | |
| 510 | + | .bind(&[repo_id.into()])? | |
| 505 | 511 | .all() | |
| 506 | 512 | .await? | |
| 507 | 513 | .results::<EnvRow>()?; | |
| 508 | 514 | let mut out: Vec<Environment> = rows.iter().map(EnvRow::view).collect(); | |
| 509 | − | let mut named: Vec<String> = self.secret_environments(&repo.id).await?; | |
| 515 | + | let mut named: Vec<String> = self.secret_environments(repo_id).await?; | |
| 510 | 516 | #[derive(Deserialize)] | |
| 511 | 517 | struct Named { | |
| 512 | 518 | environment: String, | |
| ⋯ | |||
| 514 | 520 | let used = self | |
| 515 | 521 | .db | |
| 516 | 522 | .prepare("SELECT DISTINCT environment FROM jobs WHERE repo_id = ? AND environment IS NOT NULL LIMIT 100") | |
| 517 | − | .bind(&[repo.id.as_str().into()])? | |
| 523 | + | .bind(&[repo_id.into()])? | |
| 518 | 524 | .all() | |
| 519 | 525 | .await? | |
| 520 | 526 | .results::<Named>()?; | |
| ⋯ | |||
| 526 | 532 | let sources = self | |
| 527 | 533 | .db | |
| 528 | 534 | .prepare("SELECT source FROM workflows WHERE repo_id = ? AND error IS NULL") | |
| 529 | − | .bind(&[repo.id.as_str().into()])? | |
| 535 | + | .bind(&[repo_id.into()])? | |
| 530 | 536 | .all() | |
| 531 | 537 | .await? | |
| 532 | 538 | .results::<Source>()?; | |
| ⋯ | |||
| 551 | 557 | } | |
| 552 | 558 | } | |
| 553 | 559 | out.sort_by(|a, b| b.protected.cmp(&a.protected).then(a.name.cmp(&b.name))); | |
| 554 | − | Ok(Outcome::Ok(out)) | |
| 560 | + | Ok(out) | |
| 555 | 561 | } | |
| 556 | 562 | ||
| 557 | 563 | pub async fn set_environment(&self, a: SetEnvironmentArgs) -> Result<Outcome<Environment>> { | |
| 51 | 51 | name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit()) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | /// What a ref made or deleted is, as `create` and `delete` name it: | |
| 55 | + | /// `branch` or `tag`, and its short name. `None` for any other ref, and | |
| 56 | + | /// for the merge queue's own branches, which come and go with every | |
| 57 | + | /// queued change. | |
| 58 | + | fn branch_or_tag(git_ref: &str) -> Option<(&'static str, &str)> { | |
| 59 | + | if git_ref.starts_with("refs/heads/g1t-queue/") { | |
| 60 | + | return None; | |
| 61 | + | } | |
| 62 | + | match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) { | |
| 63 | + | (Some(branch), _) => Some(("branch", branch)), | |
| 64 | + | (_, Some(tag)) => Some(("tag", tag)), | |
| 65 | + | _ => None, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Events GitHub takes no `branches`, `tags` or `paths` filters on. | |
| 70 | + | fn unfiltered(event_name: &str) -> bool { | |
| 71 | + | matches!(event_name, "create" | "delete") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Whether an `*.assigned` event added nobody: g1t says so for a change | |
| 75 | + | /// that only took people off. | |
| 76 | + | fn nobody_added(data: &Value) -> bool { | |
| 77 | + | data["added"].as_array().is_some_and(Vec::is_empty) | |
| 78 | + | } | |
| 79 | + | ||
| 54 | 80 | /// Whether whoever a pull request is for is trusted without asking | |
| 55 | 81 | /// identity: g1t's agent in work nobody asked it for, or someone whose | |
| 56 | 82 | /// role here is known to allow pushing. | |
| ⋯ | |||
| 239 | 265 | if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() { | |
| 240 | 266 | return Ok(None); | |
| 241 | 267 | } | |
| 242 | − | let (ref_type, name) = match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) { | |
| 243 | − | (Some(branch), _) => ("branch", branch), | |
| 244 | − | (_, Some(tag)) => ("tag", tag), | |
| 245 | − | _ => return Ok(None), | |
| 246 | − | }; | |
| 268 | + | let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) }; | |
| 247 | 269 | let payload = json!({ | |
| 248 | 270 | "ref": name, | |
| 249 | 271 | "ref_type": ref_type, | |
| ⋯ | |||
| 270 | 292 | approval: None, | |
| 271 | 293 | }) | |
| 272 | 294 | } | |
| 295 | + | // A branch or tag was deleted: on the default branch's latest | |
| 296 | + | // commit, as on GitHub, since what was deleted has no commit to | |
| 297 | + | // run on now. | |
| 298 | + | "delete" => { | |
| 299 | + | let Some(git_ref) = data["ref"].as_str() else { return Ok(None) }; | |
| 300 | + | let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) }; | |
| 301 | + | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 302 | + | let payload = json!({ | |
| 303 | + | "ref": name, | |
| 304 | + | "ref_type": ref_type, | |
| 305 | + | "pusher_type": "user", | |
| 306 | + | "repository": payload::repository(repo), | |
| 307 | + | "sender": payload::user(sender), | |
| 308 | + | }); | |
| 309 | + | Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None)) | |
| 310 | + | } | |
| 273 | 311 | "pull_request" | "pull_request_target" | "pull_request_review" => { | |
| 274 | 312 | let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) }; | |
| 313 | + | // Assignees changed but nobody was added: someone was only | |
| 314 | + | // taken off, which is not `assigned`. | |
| 315 | + | if action == Some("assigned") && nobody_added(data) { | |
| 316 | + | return Ok(None); | |
| 317 | + | } | |
| 275 | 318 | let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?; | |
| 276 | 319 | let Outcome::Ok(detail) = detail else { return Ok(None) }; | |
| 277 | 320 | let pull = &detail.pull; | |
| ⋯ | |||
| 382 | 425 | } | |
| 383 | 426 | "issues" | "issue_comment" => { | |
| 384 | 427 | let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) }; | |
| 428 | + | if action == Some("assigned") && nobody_added(data) { | |
| 429 | + | return Ok(None); | |
| 430 | + | } | |
| 385 | 431 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 386 | 432 | let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?; | |
| 387 | 433 | let (issue_json, comments, title, on_pull) = match issue { | |
| ⋯ | |||
| 562 | 608 | // read from git. Agents make many of these events. | |
| 563 | 609 | // Deployments' statuses are as frequent (every g1t.page build | |
| 564 | 610 | // reports several), so they look there first too. | |
| 565 | − | if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status") | |
| 611 | + | if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete") | |
| 566 | 612 | && self.listens(repo_id, event_name).await? == Some(false) | |
| 567 | 613 | { | |
| 568 | 614 | continue; | |
| ⋯ | |||
| 681 | 727 | ||
| 682 | 728 | /// Whether the branch, tag and path filters let the event through. | |
| 683 | 729 | async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> { | |
| 730 | + | // GitHub takes no filters on these: a workflow with `branches` on | |
| 731 | + | // `create` or `delete` still runs for every branch and tag. | |
| 732 | + | if unfiltered(event_name) { | |
| 733 | + | return Ok(true); | |
| 734 | + | } | |
| 684 | 735 | let git_ref = subject.filter_ref.as_str(); | |
| 685 | 736 | if let Some(tag) = git_ref.strip_prefix("refs/tags/") { | |
| 686 | 737 | // A tag push runs a workflow that filters tags, or filters nothing. | |
| ⋯ | |||
| 898 | 949 | let Some(trigger) = workflow.trigger("workflow_dispatch") else { | |
| 899 | 950 | return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger.")); | |
| 900 | 951 | }; | |
| 901 | − | let inputs = check!(dispatch_inputs(trigger, &a.inputs)); | |
| 952 | + | // `type: environment` names one of the repository's environments. | |
| 953 | + | let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) { | |
| 954 | + | self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect() | |
| 955 | + | } else { | |
| 956 | + | Vec::new() | |
| 957 | + | }; | |
| 958 | + | let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments)); | |
| 902 | 959 | let payload = json!({ | |
| 903 | − | "inputs": inputs, | |
| 960 | + | // As GitHub gives them here: text, `true` and `false` included. | |
| 961 | + | // The `inputs` context keeps booleans and numbers. | |
| 962 | + | "inputs": event_inputs(&inputs), | |
| 904 | 963 | "ref": full_ref, | |
| 905 | 964 | "repository": payload::repository(&repo), | |
| 906 | 965 | "sender": payload::user(&a.actor.username), | |
| ⋯ | |||
| 1136 | 1195 | } | |
| 1137 | 1196 | ||
| 1138 | 1197 | /// The inputs of a manual run: what was given, checked against the | |
| 1139 | − | /// workflow's declared inputs, with their defaults filled in. | |
| 1140 | − | fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>) -> Outcome<Map<String, Value>> { | |
| 1198 | + | /// workflow's declared inputs, with their defaults filled in. An input the | |
| 1199 | + | /// workflow does not declare is refused, as GitHub refuses it. | |
| 1200 | + | /// `environments` are the repository's, for `type: environment`. | |
| 1201 | + | fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> { | |
| 1202 | + | let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect(); | |
| 1203 | + | if !unexpected.is_empty() { | |
| 1204 | + | let names = serde_json::to_string(&unexpected).unwrap_or_default(); | |
| 1205 | + | let declared = if trigger.inputs.is_empty() { | |
| 1206 | + | "The workflow declares no inputs.".to_owned() | |
| 1207 | + | } else { | |
| 1208 | + | format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", ")) | |
| 1209 | + | }; | |
| 1210 | + | return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}")); | |
| 1211 | + | } | |
| 1141 | 1212 | let mut inputs = Map::new(); | |
| 1142 | 1213 | for (name, spec) in &trigger.inputs { | |
| 1143 | 1214 | let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string"); | |
| ⋯ | |||
| 1152 | 1223 | Some(value) => match kind { | |
| 1153 | 1224 | "boolean" => Value::Bool(match &value { | |
| 1154 | 1225 | Value::Bool(flag) => *flag, | |
| 1155 | − | Value::String(text) => text == "true", | |
| 1156 | − | _ => false, | |
| 1226 | + | Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true, | |
| 1227 | + | Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false, | |
| 1228 | + | _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")), | |
| 1157 | 1229 | }), | |
| 1158 | 1230 | "number" => match &value { | |
| 1159 | 1231 | Value::Number(_) => value, | |
| 1160 | − | Value::String(text) => match text.parse::<f64>().ok().and_then(serde_json::Number::from_f64) { | |
| 1232 | + | Value::String(text) => match number(text) { | |
| 1161 | 1233 | Some(number) => Value::Number(number), | |
| 1162 | 1234 | None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")), | |
| 1163 | 1235 | }, | |
| ⋯ | |||
| 1172 | 1244 | } | |
| 1173 | 1245 | Value::String(text) | |
| 1174 | 1246 | } | |
| 1247 | + | "environment" => { | |
| 1248 | + | let text = g1t_actions::expr::to_text(&value); | |
| 1249 | + | let wanted = text.trim().to_ascii_lowercase(); | |
| 1250 | + | if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) { | |
| 1251 | + | return fail( | |
| 1252 | + | FailureCode::Invalid, | |
| 1253 | + | format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()), | |
| 1254 | + | ); | |
| 1255 | + | } | |
| 1256 | + | Value::String(text) | |
| 1257 | + | } | |
| 1175 | 1258 | _ => Value::String(g1t_actions::expr::to_text(&value)), | |
| 1176 | 1259 | }, | |
| 1177 | 1260 | }; | |
| ⋯ | |||
| 1180 | 1263 | Outcome::Ok(inputs) | |
| 1181 | 1264 | } | |
| 1182 | 1265 | ||
| 1266 | + | /// A number input given as text: a whole number stays one. | |
| 1267 | + | fn number(text: &str) -> Option<serde_json::Number> { | |
| 1268 | + | let text = text.trim(); | |
| 1269 | + | match text.parse::<i64>() { | |
| 1270 | + | Ok(whole) => Some(whole.into()), | |
| 1271 | + | Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64), | |
| 1272 | + | } | |
| 1273 | + | } | |
| 1274 | + | ||
| 1275 | + | /// A manual run's inputs as `github.event.inputs` gives them: every one as | |
| 1276 | + | /// text, as GitHub's `workflow_dispatch` payload has them. | |
| 1277 | + | fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> { | |
| 1278 | + | inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect() | |
| 1279 | + | } | |
| 1280 | + | ||
| 1183 | 1281 | #[cfg(test)] | |
| 1184 | 1282 | mod tests { | |
| 1185 | 1283 | use super::*; | |
| ⋯ | |||
| 1290 | 1388 | let mut labeled = serde_json::json!({ "action": "labeled" }); | |
| 1291 | 1389 | payload::changed(&mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } })); | |
| 1292 | 1390 | assert_eq!(labeled["label"]["color"], "d73a4a"); | |
| 1391 | + | let mut assigned = serde_json::json!({ "action": "assigned" }); | |
| 1392 | + | payload::changed(&mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] })); | |
| 1393 | + | assert_eq!(assigned["assignee"]["login"], "bo"); | |
| 1394 | + | assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] }))); | |
| 1395 | + | assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] }))); | |
| 1396 | + | } | |
| 1397 | + | ||
| 1398 | + | fn dispatchable(yaml: &str) -> Trigger { | |
| 1399 | + | workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone() | |
| 1400 | + | } | |
| 1401 | + | ||
| 1402 | + | fn given(value: Value) -> Map<String, Value> { | |
| 1403 | + | value.as_object().unwrap().clone() | |
| 1404 | + | } | |
| 1405 | + | ||
| 1406 | + | const DISPATCH: &str = "on: | |
| 1407 | + | workflow_dispatch: | |
| 1408 | + | inputs: | |
| 1409 | + | dry-run: { type: boolean, default: false } | |
| 1410 | + | count: { type: number, default: 2 } | |
| 1411 | + | target: { type: environment } | |
| 1412 | + | level: { type: choice, options: [low, high], default: low } | |
| 1413 | + | note: { required: false } | |
| 1414 | + | jobs: | |
| 1415 | + | a: | |
| 1416 | + | runs-on: x | |
| 1417 | + | steps: [{ run: 'true' }]"; | |
| 1418 | + | ||
| 1419 | + | #[test] | |
| 1420 | + | fn an_input_the_workflow_does_not_declare_is_refused() { | |
| 1421 | + | let trigger = dispatchable(DISPATCH); | |
| 1422 | + | let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else { | |
| 1423 | + | panic!("an undeclared input was taken"); | |
| 1424 | + | }; | |
| 1425 | + | assert_eq!(failure.code, FailureCode::Invalid); | |
| 1426 | + | assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message); | |
| 1427 | + | assert!(failure.message.contains("`dry-run`")); | |
| 1428 | + | let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]"); | |
| 1429 | + | let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() }; | |
| 1430 | + | assert!(failure.message.contains("declares no inputs")); | |
| 1431 | + | assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty())); | |
| 1432 | + | } | |
| 1433 | + | ||
| 1434 | + | #[test] | |
| 1435 | + | fn inputs_are_typed_and_the_event_has_them_as_text() { | |
| 1436 | + | let trigger = dispatchable(DISPATCH); | |
| 1437 | + | let environments = vec!["staging".to_owned(), "production".to_owned()]; | |
| 1438 | + | let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments) | |
| 1439 | + | else { | |
| 1440 | + | panic!("refused"); | |
| 1441 | + | }; | |
| 1442 | + | assert_eq!(inputs["dry-run"], json!(true)); | |
| 1443 | + | assert_eq!(inputs["count"], json!(3)); | |
| 1444 | + | assert_eq!(inputs["level"], json!("low")); | |
| 1445 | + | assert_eq!(inputs["note"], json!("")); | |
| 1446 | + | let event = event_inputs(&inputs); | |
| 1447 | + | assert_eq!(event["dry-run"], json!("true")); | |
| 1448 | + | assert_eq!(event["count"], json!("3")); | |
| 1449 | + | assert_eq!(event["target"], json!("Staging")); | |
| 1450 | + | let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() }; | |
| 1451 | + | assert_eq!(event_inputs(&defaults)["dry-run"], json!("false")); | |
| 1452 | + | assert_eq!(defaults["count"], json!(2)); | |
| 1453 | + | let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) { | |
| 1454 | + | Outcome::Fail(failure) => failure.message, | |
| 1455 | + | Outcome::Ok(_) => panic!("taken"), | |
| 1456 | + | }; | |
| 1457 | + | assert!(refused(json!({ "target": "qa" })).contains("none called `qa`")); | |
| 1458 | + | assert!(refused(json!({ "dry-run": "yes" })).contains("true or false")); | |
| 1459 | + | assert!(refused(json!({ "count": "many" })).contains("is a number")); | |
| 1460 | + | assert!(refused(json!({ "level": "mid" })).contains("low, high")); | |
| 1461 | + | } | |
| 1462 | + | ||
| 1463 | + | #[test] | |
| 1464 | + | fn create_and_delete_take_no_filters_and_name_their_ref() { | |
| 1465 | + | assert!(unfiltered("create") && unfiltered("delete")); | |
| 1466 | + | assert!(!unfiltered("push") && !unfiltered("pull_request")); | |
| 1467 | + | assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x"))); | |
| 1468 | + | assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0"))); | |
| 1469 | + | assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None); | |
| 1470 | + | assert_eq!(branch_or_tag("refs/notes/commits"), None); | |
| 1471 | + | // A workflow can name `delete`, and filters on it are kept but not | |
| 1472 | + | // applied (`passes`). | |
| 1473 | + | let workflow = workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap(); | |
| 1474 | + | let trigger = workflow.trigger("delete").unwrap(); | |
| 1475 | + | assert!(trigger.wants_type(None)); | |
| 1476 | + | assert!(!workflow.notes.iter().any(|note| note.severity == workflow::Severity::Unsupported)); | |
| 1293 | 1477 | } | |
| 1294 | 1478 | } | |
| 480 | 480 | .collect() | |
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | + | /// The branches and tags a push asks to delete: each full ref, with the | |
| 484 | + | /// commit it pointed to. | |
| 485 | + | fn deleted_refs(body: &[u8]) -> Vec<(String, String)> { | |
| 486 | + | commands(body) | |
| 487 | + | .0 | |
| 488 | + | .into_iter() | |
| 489 | + | .filter(|command| command.new == ZERO_ID && command.old != ZERO_ID) | |
| 490 | + | .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS)) | |
| 491 | + | .map(|Command { old, name, .. }| (name, old)) | |
| 492 | + | .collect() | |
| 493 | + | } | |
| 494 | + | ||
| 483 | 495 | /// The git store's answer, and what the request asked it to change. | |
| 484 | 496 | pub struct Forwarded { | |
| 485 | 497 | pub response: Response, | |
| 486 | 498 | /// For a push: the branches and tags it asks to move, and the commits | |
| 487 | 499 | /// to move them to. Whether each moved is for the caller to confirm. | |
| 488 | 500 | pub pushed: Vec<Pushed>, | |
| 501 | + | /// For a push: the branches and tags it asks to delete, each with the | |
| 502 | + | /// commit it pointed to. Whether each went is for the caller to confirm. | |
| 503 | + | pub deleted: Vec<(String, String)>, | |
| 489 | 504 | /// For a push: the size of the pack it sent, for the storage meter. | |
| 490 | 505 | pub pack_bytes: u64, | |
| 491 | 506 | /// The bytes sent to the store. | |
| ⋯ | |||
| 988 | 1003 | return Ok(Push::Forwarded(Forwarded { | |
| 989 | 1004 | response: busy_response(busy)?, | |
| 990 | 1005 | pushed: Vec::new(), | |
| 1006 | + | deleted: Vec::new(), | |
| 991 | 1007 | pack_bytes: 0, | |
| 992 | 1008 | sent, | |
| 993 | 1009 | from_store: false, | |
| ⋯ | |||
| 1027 | 1043 | Ok(Push::Forwarded(Forwarded { | |
| 1028 | 1044 | response, | |
| 1029 | 1045 | pushed: Vec::new(), | |
| 1046 | + | deleted: Vec::new(), | |
| 1030 | 1047 | pack_bytes: 0, | |
| 1031 | 1048 | sent, | |
| 1032 | 1049 | from_store: true, | |
| ⋯ | |||
| 1114 | 1131 | return Ok(Push::Declined(declined(&head, &reason, &messages)?, reason)); | |
| 1115 | 1132 | } | |
| 1116 | 1133 | let pushed = pushed_branches(&head); | |
| 1134 | + | let deleted = deleted_refs(&head); | |
| 1117 | 1135 | let mut init = RequestInit::new(); | |
| 1118 | 1136 | init.with_method(Method::Post).with_headers(headers); | |
| 1119 | 1137 | let started = g1t_kit::now_ms(); | |
| ⋯ | |||
| 1191 | 1209 | Ok(Push::Forwarded(Forwarded { | |
| 1192 | 1210 | response, | |
| 1193 | 1211 | pushed, | |
| 1212 | + | deleted, | |
| 1194 | 1213 | pack_bytes, | |
| 1195 | 1214 | sent, | |
| 1196 | 1215 | from_store: true, | |
| ⋯ | |||
| 1200 | 1219 | ||
| 1201 | 1220 | #[cfg(test)] | |
| 1202 | 1221 | mod tests { | |
| 1203 | − | use super::{Acknowledged, GitService, Pushed, RepoPath, Url, ZERO_ID, acknowledged, framed, negotiating, pack_bytes, parse, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace, with_no_thin}; | |
| 1222 | + | use super::{Acknowledged, GitService, Pushed, RepoPath, Url, ZERO_ID, acknowledged, deleted_refs, framed, negotiating, pack_bytes, parse, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace, with_no_thin}; | |
| 1204 | 1223 | ||
| 1205 | 1224 | #[test] | |
| 1206 | 1225 | fn server_timing_names_each_step_and_the_total() { | |
| ⋯ | |||
| 1530 | 1549 | }, | |
| 1531 | 1550 | ] | |
| 1532 | 1551 | ); | |
| 1552 | + | assert_eq!(deleted_refs(&body), [("refs/heads/gone".to_owned(), old.to_owned())]); | |
| 1553 | + | let tag = [pkt(&format!("{old} {ZERO_ID} refs/tags/v0\0 report-status\n")), pkt(&format!("{old} {ZERO_ID} refs/notes/x\n")), b"0000".to_vec()].concat(); | |
| 1554 | + | assert_eq!(deleted_refs(&tag), [("refs/tags/v0".to_owned(), old.to_owned())]); | |
| 1533 | 1555 | } | |
| 1534 | 1556 | ||
| 1535 | 1557 | #[test] | |
| 51 | 51 | mod workflow_gate; | |
| 52 | 52 | ||
| 53 | 53 | use g1t_contracts::events::{ | |
| 54 | − | Event, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted, | |
| 54 | + | Event, GitDeleted, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted, | |
| 55 | 55 | WorkspaceDeleting, WorkspaceRenamed, WorkspaceRestored, | |
| 56 | 56 | }; | |
| 57 | 57 | use g1t_contracts::access::{self, Capability}; | |
| ⋯ | |||
| 2115 | 2115 | } | |
| 2116 | 2116 | } | |
| 2117 | 2117 | after.ended(status, None); | |
| 2118 | − | if status == 200 && (forwarded.pack_bytes > 0 || !forwarded.pushed.is_empty()) { | |
| 2118 | + | if status == 200 && (forwarded.pack_bytes > 0 || !forwarded.pushed.is_empty() || !forwarded.deleted.is_empty()) { | |
| 2119 | 2119 | after.push = Some(PushDone { | |
| 2120 | 2120 | repo, | |
| 2121 | 2121 | pushed: forwarded.pushed, | |
| 2122 | + | deleted: forwarded.deleted, | |
| 2122 | 2123 | pack_bytes: forwarded.pack_bytes, | |
| 2123 | 2124 | caused_by_job: viewer.as_ref().and_then(g1t_contracts::events::job_run_of).map(str::to_owned), | |
| 2124 | 2125 | // The calendar credits people, never a job's, an agent's or a workspace's token. | |
| ⋯ | |||
| 2212 | 2213 | held | |
| 2213 | 2214 | } | |
| 2214 | 2215 | ||
| 2216 | + | /// A `git.deleted` event for each branch or tag a push deleted that is | |
| 2217 | + | /// gone from the store: the store can refuse one ref and accept | |
| 2218 | + | /// another. As many as a push announces of tags and branches it moves. | |
| 2219 | + | async fn publish_deleted( | |
| 2220 | + | &self, | |
| 2221 | + | repo: &Repo, | |
| 2222 | + | deleted: &[(String, String)], | |
| 2223 | + | actor: Option<&str>, | |
| 2224 | + | caused_by_job: Option<&str>, | |
| 2225 | + | ) -> Result<()> { | |
| 2226 | + | let stored = self.store.open(&store_key(repo)).await?; | |
| 2227 | + | let is_branch = |git_ref: &str| git_ref.starts_with("refs/heads/"); | |
| 2228 | + | let tags = deleted.iter().filter(|(git_ref, _)| !is_branch(git_ref)).count(); | |
| 2229 | + | let announced = deleted | |
| 2230 | + | .iter() | |
| 2231 | + | .filter(|(git_ref, _)| is_branch(git_ref)) | |
| 2232 | + | .take(MAX_PUSH_BRANCH_EVENTS) | |
| 2233 | + | .chain(deleted.iter().filter(|(git_ref, _)| !is_branch(git_ref) && tags <= MAX_PUSH_TAG_EVENTS)); | |
| 2234 | + | for (git_ref, before) in announced { | |
| 2235 | + | let named = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref); | |
| 2236 | + | let gone = match stored.log(named, 1).await { | |
| 2237 | + | Ok(head) => head.is_empty(), | |
| 2238 | + | Err(error) => { | |
| 2239 | + | worker::console_error!("whether {git_ref} of {} was deleted is not known: {error}", repo.name); | |
| 2240 | + | false | |
| 2241 | + | } | |
| 2242 | + | }; | |
| 2243 | + | if !gone { | |
| 2244 | + | continue; | |
| 2245 | + | } | |
| 2246 | + | self.publish(NewEvent { | |
| 2247 | + | kind: "git.deleted", | |
| 2248 | + | source: SOURCE, | |
| 2249 | + | repo_id: Some(repo.id.clone()), | |
| 2250 | + | actor: actor.map(str::to_owned), | |
| 2251 | + | data: GitDeleted { | |
| 2252 | + | repo_id: repo.id.clone(), | |
| 2253 | + | git_ref: git_ref.clone(), | |
| 2254 | + | before: before.clone(), | |
| 2255 | + | caused_by_job: caused_by_job.map(str::to_owned), | |
| 2256 | + | }, | |
| 2257 | + | }) | |
| 2258 | + | .await?; | |
| 2259 | + | } | |
| 2260 | + | Ok(()) | |
| 2261 | + | } | |
| 2262 | + | ||
| 2215 | 2263 | /// What a push changed, recorded once git has its answer. | |
| 2216 | 2264 | async fn record_push(&self, push: PushDone) -> Result<()> { | |
| 2217 | 2265 | let PushDone { | |
| 2218 | 2266 | repo, | |
| 2219 | 2267 | pushed, | |
| 2268 | + | deleted, | |
| 2220 | 2269 | pack_bytes, | |
| 2221 | 2270 | actor, | |
| 2222 | 2271 | unscanned, | |
| ⋯ | |||
| 2230 | 2279 | { | |
| 2231 | 2280 | worker::console_error!("stored bytes for {} not counted: {error}", repo.name); | |
| 2232 | 2281 | } | |
| 2282 | + | if !deleted.is_empty() { | |
| 2283 | + | self.publish_deleted(&repo, &deleted, actor.as_deref(), caused_by_job.as_deref()).await?; | |
| 2284 | + | } | |
| 2233 | 2285 | if pushed.is_empty() { | |
| 2234 | 2286 | return Ok(()); | |
| 2235 | 2287 | } | |
| ⋯ | |||
| 2328 | 2380 | struct PushDone { | |
| 2329 | 2381 | repo: Repo, | |
| 2330 | 2382 | pushed: Vec<git_http::Pushed>, | |
| 2383 | + | /// The branches and tags it asked to delete, each with the commit it | |
| 2384 | + | /// pointed to. | |
| 2385 | + | deleted: Vec<(String, String)>, | |
| 2331 | 2386 | pack_bytes: u64, | |
| 2332 | 2387 | actor: Option<String>, | |
| 2333 | 2388 | /// Too large to scan for secrets before it was stored. | |