Skip to content

Commit

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

# Conflicts: # apps/docs/src/content/docs/guides/actions.md

Claudeagentcommitted Parents0cf07af20e6935Browse files
17 files+364−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
295295 entry under 128 MB in one request, and g1t takes at most 100 MB in one
296296 request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and
297297 the job goes on; smaller and larger entries are saved.
298−- `on: delete`: deleting a branch or tag starts no workflows. New branches
299− and tags start `create` and `push` workflows.
298+- `on: label`: creating, editing or deleting a label starts no workflows.
299+ Putting a label on an issue or a pull request starts `issues` and
300+ `pull_request` workflows with the `labeled` type.
301+- The `pull_request` activity types `unassigned`, `locked`, `unlocked`,
302+ and `edited` for a new title or description, and the `issues` types
303+ `unassigned`, `locked`, `unlocked`, `pinned` and `unpinned`: g1t starts
304+ no workflows for them.
300305
301306 See [Not yet](/guides/actions/#not-yet). **Status.** Planned.
302307
+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 - **Background steps.** A step with `background: true`, or a `wait`,
99102 `wait-all`, `cancel` or `parallel` step, is refused: g1t does not run
100103 them yet.
12401243 `ready_for_review`, `labeled`, `unlabeled`, `milestoned`, `demilestoned`,
12411244 `assigned`, `review_requested` and `closed`, and `edited` when the branch
12421245 a pull request merges into changes; `issue_comment` workflows on
1243−`created`, `edited` (with `github.event.changes.body.from`) and `deleted`; `issues` workflows on `labeled`, `unlabeled`, `milestoned` and
1246+`created`, `edited` (with `github.event.changes.body.from`) and `deleted`; `issues` workflows on `assigned`, `labeled`, `unlabeled`, `milestoned` and
12441247 `demilestoned` too. List them under `types:` to run on them. For
1245−`labeled` and `unlabeled`, `github.event.label` names the label. A pull
1248+`labeled` and `unlabeled`, `github.event.label` names the label; for
1249+`assigned`, `github.event.assignee` names who was assigned. Assigning
1250+several people at once starts one run, which names the first of them. A pull
12461251 request's `branches` filter, `github.base_ref` and
12471252 `pull_request.base.ref` are the branch it merges into, which is not
12481253 always the default branch: see
16351640 -d '{"ref": "main", "inputs": {"environment": "staging"}}'
16361641 ```
16371642
1643+Give only the inputs the workflow declares under
1644+`on.workflow_dispatch.inputs`: a run with any other input is refused, and
1645+the answer names the inputs it did not expect. A `boolean` input is `true`
1646+or `false`, a `number` a number, a `choice` one of its `options`, and an
1647+`environment` the name of one of the repository's
1648+[environments](#environments). In the run, the `inputs` context keeps
1649+booleans and numbers as they are, and `github.event.inputs` has every
1650+input as text, `"true"` and `"3"`, as on GitHub.
1651+
16381652 To save an artifact from a script, follow the redirect:
16391653
16401654 ```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"),
230233 #[test]
231234 fn g1t_events_are_github_events() {
232235 assert_eq!(github_events("git.push"), [("push", None)]);
236+ assert_eq!(github_events("git.deleted"), [("delete", None)]);
237+ assert_eq!(github_events("pull.labeled")[1], ("pull_request_target", Some("labeled")));
238+ assert_eq!(github_events("pull.assigned")[0], ("pull_request", Some("assigned")));
239+ assert_eq!(github_events("issue.unlabeled"), [("issues", Some("unlabeled"))]);
240+ assert_eq!(github_events("issue.assigned"), [("issues", Some("assigned"))]);
233241 assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize")));
234242 assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed")));
235243 assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]);
+5−4
3131 "workflow_run",
3232 "merge_group",
3333 "create",
34+ "delete",
3435 "release",
3536 "deployment",
3637 "deployment_status",
3940 /// Events GitHub has that g1t knows of but never sends: a workflow on one
4041 /// of them is told so, rather than waiting for a run that never comes.
4142 pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
42− "delete",
43− "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.",
43+ "label",
44+ "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.",
4445 )];
4546
4647 /// The `types` each event has when a workflow gives none, as on GitHub.
11161117
11171118 #[test]
11181119 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
1119− let workflow = parse("on: [create, delete, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1120+ let workflow = parse("on: [create, delete, label, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
11201121 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
11211122 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
1122− assert!(unsupported[0].contains("`delete`"));
1123+ assert!(unsupported[0].contains("`label`"));
11231124 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
11241125 assert!(target.message.contains("default branch"));
11251126 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>> {
+198−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.
250276 if git_ref.starts_with("refs/heads/g1t-queue/") || !data["before"].is_null() {
251277 return Ok(None);
252278 }
253− let (ref_type, name) = match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) {
254− (Some(branch), _) => ("branch", branch),
255− (_, Some(tag)) => ("tag", tag),
256− _ => return Ok(None),
257− };
279+ let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
258280 let payload = json!({
259281 "ref": name,
260282 "ref_type": ref_type,
281303 approval: None,
282304 })
283305 }
306+ // A branch or tag was deleted: on the default branch's latest
307+ // commit, as on GitHub, since what was deleted has no commit to
308+ // run on now.
309+ "delete" => {
310+ let Some(git_ref) = data["ref"].as_str() else { return Ok(None) };
311+ let Some((ref_type, name)) = branch_or_tag(git_ref) else { return Ok(None) };
312+ let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
313+ let payload = json!({
314+ "ref": name,
315+ "ref_type": ref_type,
316+ "pusher_type": "user",
317+ "repository": payload::repository(repo),
318+ "sender": payload::user(sender),
319+ });
320+ Some(on_default(sha, payload, format!("Deleted {ref_type} {name}"), None))
321+ }
284322 "pull_request" | "pull_request_target" | "pull_request_review" => {
285323 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
324+ // Assignees changed but nobody was added: someone was only
325+ // taken off, which is not `assigned`.
326+ if action == Some("assigned") && nobody_added(data) {
327+ return Ok(None);
328+ }
286329 let detail: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
287330 let Outcome::Ok(detail) = detail else { return Ok(None) };
288331 let pull = &detail.pull;
393436 }
394437 "issues" | "issue_comment" => {
395438 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
439+ if action == Some("assigned") && nobody_added(data) {
440+ return Ok(None);
441+ }
396442 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
397443 let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
398444 let (issue_json, comments, title, on_pull) = match issue {
573619 // read from git. Agents make many of these events.
574620 // Deployments' statuses are as frequent (every g1t.page build
575621 // reports several), so they look there first too.
576− if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status")
622+ if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
577623 && self.listens(repo_id, event_name).await? == Some(false)
578624 {
579625 continue;
703749
704750 /// Whether the branch, tag and path filters let the event through.
705751 async fn passes(&self, repo: &Repo, ws: &User, trigger: &Trigger, subject: &mut Subject, event_name: &str) -> Result<bool> {
752+ // GitHub takes no filters on these: a workflow with `branches` on
753+ // `create` or `delete` still runs for every branch and tag.
754+ if unfiltered(event_name) {
755+ return Ok(true);
756+ }
706757 let git_ref = subject.filter_ref.as_str();
707758 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
708759 // A tag push runs a workflow that filters tags, or filters nothing.
920971 let Some(trigger) = workflow.trigger("workflow_dispatch") else {
921972 return Ok(fail(FailureCode::Invalid, "That workflow cannot be run by hand: it has no `workflow_dispatch` trigger."));
922973 };
923− let inputs = check!(dispatch_inputs(trigger, &a.inputs));
974+ // `type: environment` names one of the repository's environments.
975+ let environments = if trigger.inputs.values().any(|spec| spec.get("type").and_then(Value::as_str) == Some("environment")) {
976+ self.environment_list(&repo.id).await?.into_iter().map(|env| env.name).collect()
977+ } else {
978+ Vec::new()
979+ };
980+ let inputs = check!(dispatch_inputs(trigger, &a.inputs, &environments));
924981 let payload = json!({
925− "inputs": inputs,
982+ // As GitHub gives them here: text, `true` and `false` included.
983+ // The `inputs` context keeps booleans and numbers.
984+ "inputs": event_inputs(&inputs),
926985 "ref": full_ref,
927986 "repository": payload::repository(&repo),
928987 "sender": payload::user(&a.actor.username),
11581217 }
11591218
11601219 /// The inputs of a manual run: what was given, checked against the
1161−/// workflow's declared inputs, with their defaults filled in.
1162−fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>) -> Outcome<Map<String, Value>> {
1220+/// workflow's declared inputs, with their defaults filled in. An input the
1221+/// workflow does not declare is refused, as GitHub refuses it.
1222+/// `environments` are the repository's, for `type: environment`.
1223+fn dispatch_inputs(trigger: &Trigger, given: &Map<String, Value>, environments: &[String]) -> Outcome<Map<String, Value>> {
1224+ let unexpected: Vec<&String> = given.keys().filter(|name| !trigger.inputs.contains_key(*name)).collect();
1225+ if !unexpected.is_empty() {
1226+ let names = serde_json::to_string(&unexpected).unwrap_or_default();
1227+ let declared = if trigger.inputs.is_empty() {
1228+ "The workflow declares no inputs.".to_owned()
1229+ } else {
1230+ format!("The workflow declares {}.", trigger.inputs.keys().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(", "))
1231+ };
1232+ return fail(FailureCode::Invalid, format!("Unexpected inputs provided: {names}. {declared}"));
1233+ }
11631234 let mut inputs = Map::new();
11641235 for (name, spec) in &trigger.inputs {
11651236 let kind = spec.get("type").and_then(Value::as_str).unwrap_or("string");
11741245 Some(value) => match kind {
11751246 "boolean" => Value::Bool(match &value {
11761247 Value::Bool(flag) => *flag,
1177− Value::String(text) => text == "true",
1178− _ => false,
1248+ Value::String(text) if text.trim().eq_ignore_ascii_case("true") => true,
1249+ Value::String(text) if text.trim().eq_ignore_ascii_case("false") || text.trim().is_empty() => false,
1250+ _ => return fail(FailureCode::Invalid, format!("The input `{name}` is a boolean: true or false.")),
11791251 }),
11801252 "number" => match &value {
11811253 Value::Number(_) => value,
1182− Value::String(text) => match text.parse::<f64>().ok().and_then(serde_json::Number::from_f64) {
1254+ Value::String(text) => match number(text) {
11831255 Some(number) => Value::Number(number),
11841256 None => return fail(FailureCode::Invalid, format!("The input `{name}` is a number.")),
11851257 },
11941266 }
11951267 Value::String(text)
11961268 }
1269+ "environment" => {
1270+ let text = g1t_actions::expr::to_text(&value);
1271+ let wanted = text.trim().to_ascii_lowercase();
1272+ if !wanted.is_empty() && !environments.iter().any(|env| env.eq_ignore_ascii_case(&wanted)) {
1273+ return fail(
1274+ FailureCode::Invalid,
1275+ format!("The input `{name}` names an environment, and this repository has none called `{}`.", text.trim()),
1276+ );
1277+ }
1278+ Value::String(text)
1279+ }
11971280 _ => Value::String(g1t_actions::expr::to_text(&value)),
11981281 },
11991282 };
12021285 Outcome::Ok(inputs)
12031286 }
12041287
1288+/// A number input given as text: a whole number stays one.
1289+fn number(text: &str) -> Option<serde_json::Number> {
1290+ let text = text.trim();
1291+ match text.parse::<i64>() {
1292+ Ok(whole) => Some(whole.into()),
1293+ Err(_) => text.parse::<f64>().ok().filter(|n| n.is_finite()).and_then(serde_json::Number::from_f64),
1294+ }
1295+}
1296+
1297+/// A manual run's inputs as `github.event.inputs` gives them: every one as
1298+/// text, as GitHub's `workflow_dispatch` payload has them.
1299+fn event_inputs(inputs: &Map<String, Value>) -> Map<String, Value> {
1300+ inputs.iter().map(|(name, value)| (name.clone(), Value::String(g1t_actions::expr::to_text(value)))).collect()
1301+}
1302+
12051303 #[cfg(test)]
12061304 mod tests {
12071305 use super::*;
13121410 let mut labeled = serde_json::json!({ "action": "labeled" });
13131411 payload::changed(&mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } }));
13141412 assert_eq!(labeled["label"]["color"], "d73a4a");
1413+ let mut assigned = serde_json::json!({ "action": "assigned" });
1414+ payload::changed(&mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
1415+ assert_eq!(assigned["assignee"]["login"], "bo");
1416+ assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
1417+ assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
1418+ }
1419+
1420+ fn dispatchable(yaml: &str) -> Trigger {
1421+ workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
1422+ }
1423+
1424+ fn given(value: Value) -> Map<String, Value> {
1425+ value.as_object().unwrap().clone()
1426+ }
1427+
1428+ const DISPATCH: &str = "on:
1429+ workflow_dispatch:
1430+ inputs:
1431+ dry-run: { type: boolean, default: false }
1432+ count: { type: number, default: 2 }
1433+ target: { type: environment }
1434+ level: { type: choice, options: [low, high], default: low }
1435+ note: { required: false }
1436+jobs:
1437+ a:
1438+ runs-on: x
1439+ steps: [{ run: 'true' }]";
1440+
1441+ #[test]
1442+ fn an_input_the_workflow_does_not_declare_is_refused() {
1443+ let trigger = dispatchable(DISPATCH);
1444+ let Outcome::Fail(failure) = dispatch_inputs(&trigger, &given(json!({ "dry-run": true, "colour": "red" })), &[]) else {
1445+ panic!("an undeclared input was taken");
1446+ };
1447+ assert_eq!(failure.code, FailureCode::Invalid);
1448+ assert!(failure.message.starts_with(r#"Unexpected inputs provided: ["colour"]."#), "{}", failure.message);
1449+ assert!(failure.message.contains("`dry-run`"));
1450+ let bare = dispatchable("on: workflow_dispatch\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]");
1451+ let Outcome::Fail(failure) = dispatch_inputs(&bare, &given(json!({ "x": "1" })), &[]) else { panic!() };
1452+ assert!(failure.message.contains("declares no inputs"));
1453+ assert!(matches!(dispatch_inputs(&bare, &Map::new(), &[]), Outcome::Ok(inputs) if inputs.is_empty()));
1454+ }
1455+
1456+ #[test]
1457+ fn inputs_are_typed_and_the_event_has_them_as_text() {
1458+ let trigger = dispatchable(DISPATCH);
1459+ let environments = vec!["staging".to_owned(), "production".to_owned()];
1460+ let Outcome::Ok(inputs) = dispatch_inputs(&trigger, &given(json!({ "dry-run": "true", "count": "3", "target": "Staging" })), &environments)
1461+ else {
1462+ panic!("refused");
1463+ };
1464+ assert_eq!(inputs["dry-run"], json!(true));
1465+ assert_eq!(inputs["count"], json!(3));
1466+ assert_eq!(inputs["level"], json!("low"));
1467+ assert_eq!(inputs["note"], json!(""));
1468+ let event = event_inputs(&inputs);
1469+ assert_eq!(event["dry-run"], json!("true"));
1470+ assert_eq!(event["count"], json!("3"));
1471+ assert_eq!(event["target"], json!("Staging"));
1472+ let Outcome::Ok(defaults) = dispatch_inputs(&trigger, &Map::new(), &environments) else { panic!() };
1473+ assert_eq!(event_inputs(&defaults)["dry-run"], json!("false"));
1474+ assert_eq!(defaults["count"], json!(2));
1475+ let refused = |value: Value| match dispatch_inputs(&trigger, &given(value), &environments) {
1476+ Outcome::Fail(failure) => failure.message,
1477+ Outcome::Ok(_) => panic!("taken"),
1478+ };
1479+ assert!(refused(json!({ "target": "qa" })).contains("none called `qa`"));
1480+ assert!(refused(json!({ "dry-run": "yes" })).contains("true or false"));
1481+ assert!(refused(json!({ "count": "many" })).contains("is a number"));
1482+ assert!(refused(json!({ "level": "mid" })).contains("low, high"));
1483+ }
1484+
1485+ #[test]
1486+ fn create_and_delete_take_no_filters_and_name_their_ref() {
1487+ assert!(unfiltered("create") && unfiltered("delete"));
1488+ assert!(!unfiltered("push") && !unfiltered("pull_request"));
1489+ assert_eq!(branch_or_tag("refs/heads/feature/x"), Some(("branch", "feature/x")));
1490+ assert_eq!(branch_or_tag("refs/tags/v1.0"), Some(("tag", "v1.0")));
1491+ assert_eq!(branch_or_tag("refs/heads/g1t-queue/main/3"), None);
1492+ assert_eq!(branch_or_tag("refs/notes/commits"), None);
1493+ // A workflow can name `delete`. It takes no filters, as GitHub's
1494+ // schema says, so writing one is refused when the workflow is read.
1495+ assert!(workflow::parse("on:\n delete:\n branches: [release/*]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").is_err());
1496+ let workflow = workflow::parse("on:\n delete:\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1497+ let trigger = workflow.trigger("delete").unwrap();
1498+ assert!(trigger.wants_type(None));
1499+ assert!(!workflow.notes.iter().any(|note| note.severity == workflow::Severity::Unsupported));
13151500 }
13161501 }
+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.