Skip to content

Commit

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
Claudeagent and Claudeagentcommitted Parent84c72b3Browse files
17 files+363−390/17 viewed
+1−1
27292729 "repo": repo_schema(),
27302730 "workflow": { "type": "string", "description": "The workflow's id or file name." },
27312731 "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." },
27332733 }),
27342734 &["repo", "workflow"],
27352735 ),
+7−2
279279 entry under 128 MB in one request, and g1t takes at most 100 MB in one
280280 request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and
281281 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.
284289
285290 See [Not yet](/guides/actions/#not-yet). **Status.** Planned.
286291
+19−5
4949
5050 | On GitHub | On g1t |
5151 | --- | --- |
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). |
5353 | `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. |
5454 | `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. |
5555 | `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. |
9393 github.com. `actions/upload-artifact`, `actions/download-artifact` and
9494 `actions/upload-artifact/merge` work, because g1t runs them itself. See
9595 [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`.
98101
99102 Why each of these is missing, and what to use instead, is on
100103 [What g1t can't do yet](/about/limitations/#actions-and-runners).
10991102 `ready_for_review`, `labeled`, `unlabeled`, `milestoned`, `demilestoned`,
11001103 `assigned`, `review_requested` and `closed`, and `edited` when the branch
11011104 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
11031106 `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
11051110 request's `branches` filter, `github.base_ref` and
11061111 `pull_request.base.ref` are the branch it merges into, which is not
11071112 always the default branch: see
14941499 -d '{"ref": "main", "inputs": {"environment": "staging"}}'
14951500 ```
14961501
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+
14971511 To save an artifact from a script, follow the redirect:
14981512
14991513 ```sh
+1−0
7373 | `repo.renamed` | It was given a new name. Its old address redirects. |
7474 | `repo.transferred` | It moved to another workspace. |
7575 | `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. |
7677 | `branch.renamed` | A branch was renamed. |
7778 | `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/). |
7879 | `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`. |
+0−0

Binary or large file; its contents are not shown.

+9−1
66
77 /// The GitHub event and activity type for a g1t event, if it has one.
88 /// 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`.
1012 pub fn github_events(kind: &str) -> Vec<(&'static str, Option<&'static str>)> {
1113 let pull = |action| vec![("pull_request", Some(action)), ("pull_request_target", Some(action))];
1214 match kind {
1315 "git.push" => vec![("push", None)],
16+ "git.deleted" => vec![("delete", None)],
1417 "pull.opened" => pull("opened"),
1518 "pull.updated" => pull("synchronize"),
1619 "pull.ready" => pull("ready_for_review"),
215218 #[test]
216219 fn g1t_events_are_github_events() {
217220 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"))]);
218226 assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize")));
219227 assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed")));
220228 assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]);
+5−4
3030 "workflow_run",
3131 "merge_group",
3232 "create",
33+ "delete",
3334 "release",
3435 "deployment",
3536 "deployment_status",
3839 /// Events GitHub has that g1t knows of but never sends: a workflow on one
3940 /// of them is told so, rather than waiting for a run that never comes.
4041 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.",
4344 )];
4445
4546 /// The `types` each event has when a workflow gives none, as on GitHub.
769770
770771 #[test]
771772 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();
773774 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
774775 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
775− assert!(unsupported[0].contains("`delete`"));
776+ assert!(unsupported[0].contains("`label`"));
776777 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
777778 assert!(target.message.contains("default branch"));
778779 assert!(!target.message.contains("on the pull request's head"));
+15−0
129129 pub commits: Option<u32>,
130130 }
131131
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+
132147 /// The payload of `issue.opened`, `issue.updated`, `issue.assigned`,
133148 /// `issue.labeled`, `issue.unlabeled`, `issue.milestoned`,
134149 /// `issue.demilestoned`, `issue.closed` and `issue.reopened`; each uses
+1−0
7575 "SUBSCRIBER_ACTIONS",
7676 &[
7777 "git.push",
78+ "git.deleted",
7879 "pull.*",
7980 "issue.*",
8081 "comment.*",
+2−1
1515 use crate::{User, Viewer};
1616
1717 /// 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] = [
1919 "git.push",
20+ "git.deleted",
2021 "branch.renamed",
2122 "repo.created",
2223 "repo.forked",
+5−0
224224 commits?: number;
225225 };
226226 /**
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+ /**
227232 * `author` is who opened it: g1t, for one its agent filed while at work,
228233 * with `requestedBy` the person it was working for. Every issue and pull
229234 * request event carries both.
+1−0
1010 /** Every event a webhook can be sent, in the order people are shown them. */
1111 export const EVENT_TYPES = [
1212 "git.push",
13+ "git.deleted",
1314 "branch.renamed",
1415 "repo.created",
1516 "repo.forked",
+9−4
153153 })
154154 }
155155
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`.
160162 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+ }
161166 if let Some(label) = data.get("label").filter(|label| label.is_object()) {
162167 payload["label"] = json!({ "name": label["name"], "color": label["color"] });
163168 }
+11−5
498498 let name = check!(environment_name(name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok));
499499 return Ok(Outcome::Ok(vec![self.environment_rules(&repo.id, &name).await?]));
500500 }
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>> {
501507 let rows = self
502508 .db
503509 .prepare("SELECT * FROM environments WHERE repo_id = ? ORDER BY name")
504− .bind(&[repo.id.as_str().into()])?
510+ .bind(&[repo_id.into()])?
505511 .all()
506512 .await?
507513 .results::<EnvRow>()?;
508514 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?;
510516 #[derive(Deserialize)]
511517 struct Named {
512518 environment: String,
514520 let used = self
515521 .db
516522 .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()])?
518524 .all()
519525 .await?
520526 .results::<Named>()?;
526532 let sources = self
527533 .db
528534 .prepare("SELECT source FROM workflows WHERE repo_id = ? AND error IS NULL")
529− .bind(&[repo.id.as_str().into()])?
535+ .bind(&[repo_id.into()])?
530536 .all()
531537 .await?
532538 .results::<Source>()?;
551557 }
552558 }
553559 out.sort_by(|a, b| b.protected.cmp(&a.protected).then(a.name.cmp(&b.name)));
554− Ok(Outcome::Ok(out))
560+ Ok(out)
555561 }
556562
557563 pub async fn set_environment(&self, a: SetEnvironmentArgs) -> Result<Outcome<Environment>> {
+197−13
5151 name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())
5252 }
5353
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+
5480 /// Whether whoever a pull request is for is trusted without asking
5581 /// identity: g1t's agent in work nobody asked it for, or someone whose
5682 /// role here is known to allow pushing.
239265 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
240266 return Ok(None);
241267 }
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) };
247269 let payload = json!({
248270 "ref": name,
249271 "ref_type": ref_type,
270292 approval: None,
271293 })
272294 }
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+ }
273311 "pull_request" | "pull_request_target" | "pull_request_review" => {
274312 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+ }
275318 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
276319 let Outcome::Ok(detail) = detail else { return Ok(None) };
277320 let pull = &detail.pull;
382425 }
383426 "issues" | "issue_comment" => {
384427 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+ }
385431 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
386432 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
387433 let (issue_json, comments, title, on_pull) = match issue {
562608 // read from git. Agents make many of these events.
563609 // Deployments' statuses are as frequent (every g1t.page build
564610 // 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")
566612 && self.listens(repo_id, event_name).await? == Some(false)
567613 {
568614 continue;
681727
682728 /// Whether the branch, tag and path filters let the event through.
683729 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+ }
684735 let git_ref = subject.filter_ref.as_str();
685736 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
686737 // A tag push runs a workflow that filters tags, or filters nothing.
898949 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
899950 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
900951 };
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));
902959 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),
904963 "ref": full_ref,
905964 "repository": payload::repository(&repo),
906965 "sender": payload::user(&a.actor.username),
11361195 }
11371196
11381197 /// 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+ }
11411212 let mut inputs = Map::new();
11421213 for (name, spec) in &trigger.inputs {
11431214 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
11521223 Some(value) => match kind {
11531224 "boolean" => Value::Bool(match &value {
11541225 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.")),
11571229 }),
11581230 "number" => match &value {
11591231 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) {
11611233 Some(number) => Value::Number(number),
11621234 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
11631235 },
11721244 }
11731245 Value::String(text)
11741246 }
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+ }
11751258 _ => Value::String(g1t_actions::expr::to_text(&value)),
11761259 },
11771260 };
11801263 Outcome::Ok(inputs)
11811264 }
11821265
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+
11831281 #[cfg(test)]
11841282 mod tests {
11851283 use super::*;
12901388 let mut labeled = serde_json::json!({ "action": "labeled" });
12911389 payload::changed(&mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } }));
12921390 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));
12931477 }
12941478 }
+23−1
480480 .collect()
481481 }
482482
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+
483495 /// The git store's answer, and what the request asked it to change.
484496 pub struct Forwarded {
485497 pub response: Response,
486498 /// For a push: the branches and tags it asks to move, and the commits
487499 /// to move them to. Whether each moved is for the caller to confirm.
488500 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)>,
489504 /// For a push: the size of the pack it sent, for the storage meter.
490505 pub pack_bytes: u64,
491506 /// The bytes sent to the store.
9881003 return Ok(Push::Forwarded(Forwarded {
9891004 response: busy_response(busy)?,
9901005 pushed: Vec::new(),
1006+ deleted: Vec::new(),
9911007 pack_bytes: 0,
9921008 sent,
9931009 from_store: false,
10271043 Ok(Push::Forwarded(Forwarded {
10281044 response,
10291045 pushed: Vec::new(),
1046+ deleted: Vec::new(),
10301047 pack_bytes: 0,
10311048 sent,
10321049 from_store: true,
11141131 return Ok(Push::Declined(declined(&head, &reason, &messages)?, reason));
11151132 }
11161133 let pushed = pushed_branches(&head);
1134+ let deleted = deleted_refs(&head);
11171135 let mut init = RequestInit::new();
11181136 init.with_method(Method::Post).with_headers(headers);
11191137 let started = g1t_kit::now_ms();
11911209 Ok(Push::Forwarded(Forwarded {
11921210 response,
11931211 pushed,
1212+ deleted,
11941213 pack_bytes,
11951214 sent,
11961215 from_store: true,
12001219
12011220 #[cfg(test)]
12021221 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};
12041223
12051224 #[test]
12061225 fn server_timing_names_each_step_and_the_total() {
15301549 },
15311550 ]
15321551 );
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())]);
15331555 }
15341556
15351557 #[test]
+57−2
5151 mod workflow_gate;
5252
5353 use g1t_contracts::events::{
54− Event, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted,
54+ Event, GitDeleted, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted,
5555 WorkspaceDeleting, WorkspaceRenamed, WorkspaceRestored,
5656 };
5757 use g1t_contracts::access::{self, Capability};
21152115 }
21162116 }
21172117 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()) {
21192119 after.push = Some(PushDone {
21202120 repo,
21212121 pushed: forwarded.pushed,
2122+ deleted: forwarded.deleted,
21222123 pack_bytes: forwarded.pack_bytes,
21232124 caused_by_job: viewer.as_ref().and_then(g1t_contracts::events::job_run_of).map(str::to_owned),
21242125 // The calendar credits people, never a job's, an agent's or a workspace's token.
22122213 held
22132214 }
22142215
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+
22152263 /// What a push changed, recorded once git has its answer.
22162264 async fn record_push(&self, push: PushDone) -> Result<()> {
22172265 let PushDone {
22182266 repo,
22192267 pushed,
2268+ deleted,
22202269 pack_bytes,
22212270 actor,
22222271 unscanned,
22302279 {
22312280 worker::console_error!("stored bytes for {} not counted: {error}", repo.name);
22322281 }
2282+ if !deleted.is_empty() {
2283+ self.publish_deleted(&repo, &deleted, actor.as_deref(), caused_by_job.as_deref()).await?;
2284+ }
22332285 if pushed.is_empty() {
22342286 return Ok(());
22352287 }
23282380 struct PushDone {
23292381 repo: Repo,
23302382 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)>,
23312386 pack_bytes: u64,
23322387 actor: Option<String>,
23332388 /// Too large to scan for secrets before it was stored.