Skip to content

Commit

Actions: release, deployment and deployment_status triggers; more activity types

Workflows now start on `release` (created, published, released, prereleased, edited, unpublished, deleted), at the commit its tag names, on `deployment` and `deployment_status`, at the commit deployed, on `pull_request` reopened and converted_to_draft (a reopened head runs again), and on `issue_comment` edited (github.event.changes) and deleted (the comment as the event kept it). repos publishes release.* events: release_actions turns a change into GitHub's activity types, edited carrying the old title and notes, and a job token's changes marked causedByJob. The deployments service marks deployments an Actions run or a job token made, so they start nothing and a workflow cannot set itself off. A deployment of a bare commit runs with no ref, and checkout takes the commit.

syntaqxcommitted Parent7dbb83aBrowse files
7 files+373−170/7 viewed
+23−0
1414 "pull.opened" => pull("opened"),
1515 "pull.updated" => pull("synchronize"),
1616 "pull.ready" => pull("ready_for_review"),
17+ "pull.reopened" => pull("reopened"),
18+ "pull.converted_to_draft" => pull("converted_to_draft"),
1719 "pull.closed" | "pull.merged" => pull("closed"),
1820 "pull.assigned" => pull("assigned"),
1921 "pull.review_requested" => pull("review_requested"),
3335 "issue.milestoned" => vec![("issues", Some("milestoned"))],
3436 "issue.demilestoned" => vec![("issues", Some("demilestoned"))],
3537 "comment.created" => vec![("issue_comment", Some("created"))],
38+ "comment.edited" => vec![("issue_comment", Some("edited"))],
39+ "comment.deleted" => vec![("issue_comment", Some("deleted"))],
40+ "release.created" => vec![("release", Some("created"))],
41+ "release.published" => vec![("release", Some("published"))],
42+ "release.released" => vec![("release", Some("released"))],
43+ "release.prereleased" => vec![("release", Some("prereleased"))],
44+ "release.edited" => vec![("release", Some("edited"))],
45+ "release.unpublished" => vec![("release", Some("unpublished"))],
46+ "release.deleted" => vec![("release", Some("deleted"))],
47+ "deployment.created" => vec![("deployment", None)],
48+ "deployment_status.created" => vec![("deployment_status", Some("created"))],
3649 "review.completed" => vec![("pull_request_review", Some("submitted"))],
3750 "workflow.completed" => vec![("workflow_run", Some("completed"))],
3851 _ => Vec::new(),
209222 assert_eq!(github_events("pull.unlabeled")[0], ("pull_request", Some("unlabeled")));
210223 assert_eq!(github_events("pull.milestoned")[1], ("pull_request_target", Some("milestoned")));
211224 assert_eq!(github_events("pull.base_changed")[0], ("pull_request", Some("edited")));
225+ assert_eq!(github_events("pull.reopened")[0], ("pull_request", Some("reopened")));
226+ assert_eq!(github_events("pull.converted_to_draft")[1], ("pull_request_target", Some("converted_to_draft")));
227+ assert_eq!(github_events("comment.edited"), [("issue_comment", Some("edited"))]);
228+ assert_eq!(github_events("comment.deleted"), [("issue_comment", Some("deleted"))]);
229+ assert_eq!(github_events("release.published"), [("release", Some("published"))]);
230+ assert_eq!(github_events("release.prereleased"), [("release", Some("prereleased"))]);
231+ assert_eq!(github_events("deployment_status.created"), [("deployment_status", Some("created"))]);
232+ assert_eq!(github_events("deployment.created"), [("deployment", None)]);
233+ // g1t.page's own deployment outcomes are the inbox's, not a GitHub event.
234+ assert!(github_events("deployment.succeeded").is_empty());
212235 assert!(github_events("session.appended").is_empty());
213236 }
214237
+15−5
2828 "workflow_run",
2929 "merge_group",
3030 "create",
31+ "release",
32+ "deployment",
33+ "deployment_status",
3134 ];
3235
3336 /// Events GitHub has that g1t knows of but never sends: a workflow on one
467470 ),
468471 _ => (None, true, None),
469472 };
470− if uses.as_deref().is_some_and(|uses| !uses.starts_with("./")) {
473+ if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) {
471474 note(
472− Severity::Unsupported,
475+ Severity::Info,
473476 Some(id),
474− "Reusable workflows from other repositories are not called on g1t yet, so this job fails; ones in this repository (`./.g1t/workflows/…`) are.".to_owned(),
477+ format!(
478+ "`{called}` is read from that repository on g1t when it is there and this repository may use it (a private one allows it under Settings, Actions, Access), and otherwise from a public repository on GitHub."
479+ ),
475480 );
476481 }
477482 jobs.push(Job {
645650 #[test]
646651 fn notes_say_what_runs_differently() {
647652 let workflow = parse(
648− "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v6\n - uses: actions/setup-node@v7\n with: { cache: npm }\n - uses: docker://alpine\n - run: dir\n shell: pwsh",
653+ "on: [push, watch]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v6\n - uses: actions/setup-node@v7\n with: { cache: npm }\n - uses: docker://alpine\n - run: dir\n shell: pwsh",
649654 )
650655 .unwrap();
651656 let unsupported: Vec<&str> =
652657 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
653− assert!(unsupported.iter().any(|m| m.contains("`release`")));
658+ assert!(unsupported.iter().any(|m| m.contains("`watch`")));
659+ let released = parse("on:\n release:\n types: [published]\n deployment_status:\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
660+ assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported));
661+ assert!(released.trigger("release").unwrap().wants_type(Some("published")));
662+ assert!(!released.trigger("release").unwrap().wants_type(Some("created")));
663+ assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created")));
654664 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
655665 assert!(!unsupported.iter().any(|m| m.contains("services")));
656666 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
+2−0
6565 deploy_key?: string;
6666 /** The one repository a job's token or a deploy key reaches. */
6767 repo?: string;
68+ /** Set on a workflow job's token (`G1T_TOKEN`): the run and job it was made for. */
69+ job?: { run_id: string; job_id: string; pull_requests?: boolean };
6870 };
6971 /**
7072 * Workspaces the person belongs to but cannot use until they meet its
+91−0
209209 })
210210 }
211211
212+/// A comment as the event that deleted it kept it (`comment.deleted`'s
213+/// `comment`), since it can no longer be read.
214+pub fn deleted_comment(repo: &Repo, number: u32, kept: &Value, on_pull: bool) -> Value {
215+ let full_name = format!("{}/{}", repo.namespace, repo.name);
216+ let page = if on_pull { "pull" } else { "issues" };
217+ let id = kept["id"].as_str().unwrap_or_default();
218+ let login = kept["author"]["username"].as_str().unwrap_or_default();
219+ json!({
220+ "id": id,
221+ "body": kept["body"],
222+ "user": user(login),
223+ "created_at": kept["createdAt"],
224+ "updated_at": kept["createdAt"],
225+ "path": kept["path"],
226+ "line": kept["line"],
227+ "html_url": format!("{SITE}/{full_name}/{page}/{number}#{id}"),
228+ })
229+}
230+
231+/// A release, from what a `release.*` event carries
232+/// (`g1t_contracts::about::Release`).
233+pub fn release(repo: &Repo, release: &Value) -> Value {
234+ let full_name = format!("{}/{}", repo.namespace, repo.name);
235+ let tag = release["tagName"].as_str().unwrap_or_default();
236+ let id = release["id"].as_str().unwrap_or_default();
237+ json!({
238+ "id": id,
239+ "node_id": id,
240+ "tag_name": tag,
241+ "target_commitish": release["target"],
242+ "name": release["name"].as_str().unwrap_or(tag),
243+ "body": release["body"],
244+ "draft": release["draft"].as_bool().unwrap_or(false),
245+ "prerelease": release["prerelease"].as_bool().unwrap_or(false),
246+ "created_at": release["createdAt"],
247+ "published_at": release["publishedAt"],
248+ "author": user(release["author"].as_str().unwrap_or(&repo.namespace)),
249+ "assets": [],
250+ "html_url": format!("{SITE}/{full_name}/releases/tag/{tag}"),
251+ "url": format!("{API}/repos/{full_name}/releases/{id}"),
252+ })
253+}
254+
255+/// A deployment, from what `deployment.created` and
256+/// `deployment_status.created` carry (`RepoDeployment`, already in GitHub's
257+/// spelling).
258+pub fn deployment(repo: &Repo, deployment: &Value) -> Value {
259+ let full_name = format!("{}/{}", repo.namespace, repo.name);
260+ let id = deployment["id"].as_str().unwrap_or_default();
261+ json!({
262+ "id": id,
263+ "node_id": id,
264+ "sha": deployment["sha"],
265+ "ref": deployment["ref"],
266+ "task": deployment["task"].as_str().unwrap_or("deploy"),
267+ "payload": deployment.get("payload").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})),
268+ "environment": deployment["environment"],
269+ "original_environment": deployment["environment"],
270+ "description": deployment["description"],
271+ "creator": user(deployment["creator"].as_str().unwrap_or("g1t")),
272+ "transient_environment": deployment["transient_environment"].as_bool().unwrap_or(false),
273+ "production_environment": deployment["production_environment"].as_bool().unwrap_or(false),
274+ "created_at": deployment["created_at"],
275+ "updated_at": deployment["updated_at"],
276+ "url": format!("{API}/repos/{full_name}/deployments/{id}"),
277+ "statuses_url": format!("{API}/repos/{full_name}/deployments/{id}/statuses"),
278+ })
279+}
280+
281+/// A deployment's status (`DeploymentStatus`), for `deployment_status`.
282+pub fn deployment_status(repo: &Repo, status: &Value, deployment: &Value) -> Value {
283+ let full_name = format!("{}/{}", repo.namespace, repo.name);
284+ let deployment_id = deployment["id"].as_str().unwrap_or_default();
285+ let id = status["id"].as_str().unwrap_or_default();
286+ json!({
287+ "id": id,
288+ "node_id": id,
289+ "state": status["state"],
290+ "description": status["description"].as_str().unwrap_or_default(),
291+ "environment": deployment["environment"],
292+ "environment_url": status["environment_url"].as_str().unwrap_or_default(),
293+ "log_url": status["log_url"].as_str().unwrap_or_default(),
294+ "target_url": status["log_url"].as_str().unwrap_or_default(),
295+ "creator": user(status["creator"].as_str().unwrap_or("g1t")),
296+ "created_at": status["created_at"],
297+ "updated_at": status["created_at"],
298+ "deployment_url": format!("{API}/repos/{full_name}/deployments/{deployment_id}"),
299+ "url": format!("{API}/repos/{full_name}/deployments/{deployment_id}/statuses/{id}"),
300+ })
301+}
302+
+161−6
4444 approval: Option<String>,
4545 }
4646
47+/// Whether a deployment's `ref` is a commit's full hash rather than a
48+/// branch or tag.
49+fn is_commit(name: &str) -> bool {
50+ name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())
51+}
52+
4753 /// Whether whoever a pull request is for is trusted without asking
4854 /// identity: g1t's agent in work nobody asked it for, or someone whose
4955 /// role here is known to allow pushing.
144150 .await
145151 }
146152
153+ /// Whether `name` is one of the repository's branches.
154+ async fn is_branch(&self, repo: &Repo, ws: &User, name: &str) -> Result<bool> {
155+ let branches: Outcome<Vec<g1t_contracts::repos::Branch>> = g1t_kit::call(
156+ &self.repos,
157+ "branches",
158+ &g1t_contracts::repos::BranchesArgs {
159+ path: Self::repo_path(repo),
160+ viewer: Some(ws.clone()),
161+ },
162+ )
163+ .await?;
164+ Ok(branches.into_result().unwrap_or_default().iter().any(|branch| branch.name == name))
165+ }
166+
147167 fn repo_path(repo: &Repo) -> RepoPath {
148168 RepoPath {
149169 namespace: repo.namespace.clone(),
380400 payload::changed(&mut payload, data);
381401 if event_name == "issue_comment" {
382402 let comment_id = data["commentId"].as_str();
383− let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id).or(comments.last());
384− match comment {
385− Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
386− None => return Ok(None),
403+ if action == Some("deleted") {
404+ // Gone by now: as the event kept it.
405+ match data.get("comment").filter(|kept| kept.is_object()) {
406+ Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
407+ None => return Ok(None),
408+ }
409+ } else {
410+ let comment = comments.iter().find(|c| Some(c.id.as_str()) == comment_id);
411+ // A new comment is the newest; an edited one must be found.
412+ let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
413+ match comment {
414+ Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
415+ None => return Ok(None),
416+ }
417+ }
418+ // `edited`: what the body was before.
419+ if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
420+ payload["changes"] = changes.clone();
387421 }
388422 }
389423 Some(on_default(sha, payload, title, on_pull.then_some(number)))
390424 }
425+ // A release: on its tag, at the commit the tag named.
426+ "release" => {
427+ let release = &data["release"];
428+ let Some(tag) = data["tagName"].as_str().or_else(|| release["tagName"].as_str()) else { return Ok(None) };
429+ let sha = match release["target"].as_str().filter(|target| !target.is_empty()) {
430+ Some(target) => target.to_owned(),
431+ None => match self.default_head(repo).await? {
432+ Some(head) => head,
433+ None => return Ok(None),
434+ },
435+ };
436+ let git_ref = format!("refs/tags/{tag}");
437+ let mut payload = json!({
438+ "action": action,
439+ "release": payload::release(repo, release),
440+ "repository": payload::repository(repo),
441+ "sender": payload::user(sender),
442+ });
443+ if let Some(changes) = data.get("changes").filter(|changes| changes.is_object()) {
444+ payload["changes"] = changes.clone();
445+ }
446+ let name = release["name"].as_str().filter(|name| !name.is_empty()).unwrap_or(tag);
447+ Some(Subject {
448+ source: path.clone(),
449+ source_ref: Some(sha.clone()),
450+ git_ref: git_ref.clone(),
451+ sha,
452+ head_ref: None,
453+ base_ref: None,
454+ pull: None,
455+ filter_ref: git_ref,
456+ paths: None,
457+ compare: None,
458+ payload,
459+ title: format!("Release {name} {}", action.unwrap_or("changed")),
460+ trusted: true,
461+ approval: None,
462+ })
463+ }
464+ // A deployment, or a new status of one: at the commit deployed,
465+ // on the branch or tag it names (none for a bare commit).
466+ "deployment" | "deployment_status" => {
467+ let deployment = &data["deployment"];
468+ let Some(sha) = deployment["sha"].as_str().filter(|sha| !sha.is_empty()).map(str::to_owned) else { return Ok(None) };
469+ let named = deployment["ref"].as_str().unwrap_or_default();
470+ let git_ref = if named.is_empty() || named == sha || is_commit(named) {
471+ String::new()
472+ } else if named.starts_with("refs/") {
473+ named.to_owned()
474+ } else if self.is_branch(repo, ws, named).await? {
475+ format!("refs/heads/{named}")
476+ } else {
477+ format!("refs/tags/{named}")
478+ };
479+ let environment = deployment["environment"].as_str().unwrap_or_default();
480+ let mut payload = json!({
481+ "action": "created",
482+ "deployment": payload::deployment(repo, deployment),
483+ "repository": payload::repository(repo),
484+ "sender": payload::user(sender),
485+ });
486+ let title = if event_name == "deployment_status" {
487+ let status = &data["deploymentStatus"];
488+ payload["deployment_status"] = payload::deployment_status(repo, status, deployment);
489+ format!("Deployment to {environment}: {}", status["state"].as_str().unwrap_or("changed"))
490+ } else {
491+ format!("Deployment to {environment}")
492+ };
493+ Some(Subject {
494+ source: path.clone(),
495+ source_ref: Some(sha.clone()),
496+ filter_ref: git_ref.clone(),
497+ git_ref,
498+ sha,
499+ head_ref: None,
500+ base_ref: None,
501+ pull: None,
502+ paths: None,
503+ compare: None,
504+ payload,
505+ title,
506+ trusted: true,
507+ approval: None,
508+ })
509+ }
391510 _ => None,
392511 })
393512 }
419538 // Issues and comments start the default branch's workflows,
420539 // which the synced table lists: when none listens, nothing is
421540 // read from git. Agents make many of these events.
422− if matches!(event_name, "issues" | "issue_comment") && self.listens(repo_id, event_name).await? == Some(false) {
541+ // Deployments' statuses are as frequent (every g1t.page build
542+ // reports several), so they look there first too.
543+ if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status")
544+ && self.listens(repo_id, event_name).await? == Some(false)
545+ {
423546 continue;
424547 }
425548 let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else {
430553 // events say it is there (marked ready, and pushed).
431554 let key = match subject.pull {
432555 Some(number) if event_name.starts_with("pull_request") && event_name != "pull_request_review" => {
433− let phase = if action == Some("closed") { "closed" } else { "open" };
556+ // Reopened or made a draft again runs anew, at a head
557+ // that may have run before.
558+ let phase = match action {
559+ Some("closed") => "closed".to_owned(),
560+ Some(again @ ("reopened" | "converted_to_draft")) => format!("{again}:{}", event.id),
561+ _ => "open".to_owned(),
562+ };
434563 format!("{event_name}:{number}:{}:{phase}", subject.sha)
435564 }
436565 _ if event_name == "create" => format!("{}:create", event.id),
10391168 }
10401169
10411170 #[test]
1171+ fn releases_deployments_and_deleted_comments_read_as_githubs() {
1172+ let release = payload::release(
1173+ &repo(),
1174+ &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "abc", "name": null, "body": "Notes", "draft": false,
1175+ "prerelease": true, "author": "ana", "createdAt": "2026-10-08T00:00:00Z", "publishedAt": "2026-10-08T00:00:00Z" }),
1176+ );
1177+ assert_eq!(release["tag_name"], "v1.2.0");
1178+ assert_eq!(release["name"], "v1.2.0");
1179+ assert_eq!(release["prerelease"], true);
1180+ assert_eq!(release["author"]["login"], "ana");
1181+ assert_eq!(release["html_url"], "https://g1t.sh/acme/web/releases/tag/v1.2.0");
1182+ let deployment = json!({ "id": "dep_1", "sha": "abc", "ref": "main", "environment": "staging", "creator": "ana",
1183+ "production_environment": false, "created_at": "t", "updated_at": "t" });
1184+ let status = payload::deployment_status(&repo(), &json!({ "id": "dst_1", "state": "success", "environment_url": "https://s.example", "log_url": null, "creator": "g1t", "created_at": "t" }), &deployment);
1185+ assert_eq!(status["state"], "success");
1186+ assert_eq!(status["environment"], "staging");
1187+ assert_eq!(status["environment_url"], "https://s.example");
1188+ assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1189+ let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1190+ assert_eq!(gone["user"]["login"], "bo");
1191+ assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1192+ assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
1193+ assert!(!is_commit("main"));
1194+ }
1195+
1196+ #[test]
10421197 fn a_pull_request_names_its_own_base_labels_and_milestone() {
10431198 let mut pull = made_for(person("usr_1", "syntaqx"));
10441199 let event = payload::pull(&repo(), &pull);
+18−2
455455 auto_inactive: true,
456456 created: true,
457457 actor: a.actor.kind === "system" ? null : a.actor.id,
458+ causedByJob: a.actor.token?.job?.run_id ?? null,
458459 });
459460 return this.detail(repo, id);
460461 }
486487 auto_inactive: a.auto_inactive ?? true,
487488 created: false,
488489 actor: a.actor.kind === "system" ? null : a.actor.id,
490+ causedByJob: a.actor.token?.job?.run_id ?? null,
489491 });
490492 return ok(status);
491493 }
509511 created: boolean;
510512 /** The person who caused it, by id, for the event. */
511513 actor: string | null;
514+ /**
515+ * The workflow run whose job made it, with its token or by deploying
516+ * to an environment: its events start no workflows.
517+ */
518+ causedByJob: string | null;
512519 },
513520 ): Promise<DeploymentStatus> {
514521 const at = now();
542549 if (input.state === "success" && input.auto_inactive) await this.retireOlder(repo, deployment);
543550 await this.reportOnCommit(repo, deployment, status);
544551 const events = [];
552+ const caused = input.causedByJob ? { causedByJob: input.causedByJob } : {};
545553 if (input.created) {
546− events.push({ type: "deployment.created" as const, source: "deployments", repoId: repo.id, actor: input.actor, data: { repoId: repo.id, deployment: withoutPayload(deployment) } });
554+ events.push({
555+ type: "deployment.created" as const,
556+ source: "deployments",
557+ repoId: repo.id,
558+ actor: input.actor,
559+ data: { repoId: repo.id, deployment: withoutPayload(deployment), ...caused },
560+ });
547561 }
548562 events.push({
549563 type: "deployment_status.created" as const,
550564 source: "deployments",
551565 repoId: repo.id,
552566 actor: input.actor,
553− data: { repoId: repo.id, deployment: withoutPayload(deployment), deploymentStatus: status },
567+ data: { repoId: repo.id, deployment: withoutPayload(deployment), deploymentStatus: status, ...caused },
554568 });
555569 await this.publish(events);
556570 return status;
683697 auto_inactive: true,
684698 created: true,
685699 actor: null,
700+ causedByJob: a.runId,
686701 });
687702 return ok({ id, state: a.state });
688703 }
697712 auto_inactive: true,
698713 created: false,
699714 actor: null,
715+ causedByJob: a.runId,
700716 });
701717 return ok({ id: existing.id, state: next });
702718 }
+63−4
1313
1414 use g1t_contracts::about::*;
1515 use g1t_contracts::accounts::EmailOwner;
16+use g1t_contracts::events::{NewEvent, ReleaseEvent, ReleaseState, release_actions, release_kind};
1617 use g1t_contracts::identity::{UsernameArgs, UsernamesArgs};
1718 use g1t_contracts::repos::{Repo, is_valid_branch_name};
1819 use g1t_contracts::time::rfc3339;
525526 ])?
526527 .run()
527528 .await?;
528− self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor), id: Some(id), tag: None, latest: false }).await
529+ let made = self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor.clone()), id: Some(id), tag: None, latest: false }).await?;
530+ if let Outcome::Ok(release) = &made {
531+ self.publish_release(&repo, &a.actor, None, Some(release)).await;
532+ }
533+ Ok(made)
529534 }
530535
531536 /// `update_release`.
554559 .bind(&[
555560 release.id.as_str().into(),
556561 name.map_or(JsValue::NULL, |name| name.into()),
557− a.body.unwrap_or(release.body).into(),
562+ a.body.unwrap_or_else(|| release.body.clone()).into(),
558563 JsValue::from_f64(if draft { 1.0 } else { 0.0 }),
559564 JsValue::from_f64(if a.prerelease.unwrap_or(release.prerelease) { 1.0 } else { 0.0 }),
560565 published_at.map_or(JsValue::NULL, |at| at.into()),
562567 ])?
563568 .run()
564569 .await?;
565− self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor), id: Some(release.id), tag: None, latest: false }).await
570+ let changed = self.release(ReleaseArgs { path: a.path, viewer: Some(a.actor.clone()), id: Some(release.id.clone()), tag: None, latest: false }).await?;
571+ if let Outcome::Ok(after) = &changed {
572+ self.publish_release(&repo, &a.actor, Some(&release), Some(after)).await;
573+ }
574+ Ok(changed)
566575 }
567576
568577 /// `delete_release`: the tag stays.
571580 Ok(repo) => repo,
572581 Err(refused) => return Ok(refused.retype()),
573582 };
583+ let before = self.release_row(&repo.id, Some(&a.id), None).await?;
574584 let deleted = self
575585 .db()
576586 .prepare("DELETE FROM releases WHERE repo_id = ? AND id = ? RETURNING id")
578588 .first::<serde_json::Value>(None)
579589 .await?;
580590 Ok(match deleted {
581− Some(_) => Outcome::Ok(true),
591+ Some(_) => {
592+ if let Some(before) = &before {
593+ self.publish_release(&repo, &a.actor, Some(before), None).await;
594+ }
595+ Outcome::Ok(true)
596+ }
582597 None => Outcome::fail(FailureCode::NotFound, "No such release."),
583598 })
584599 }
600+
601+ /// Publishes a release's change as the release activities it amounts
602+ /// to (`release_actions`), one `release.*` event each, for workflows
603+ /// and webhooks. What a workflow job's token did is marked as its, so it
604+ /// starts no workflows. A failure is logged: the change itself happened.
605+ async fn publish_release(&self, repo: &Repo, actor: &User, before: Option<&Release>, after: Option<&Release>) {
606+ let state = |release: &Release| ReleaseState { draft: release.draft, prerelease: release.prerelease };
607+ let Some(release) = after.or(before) else { return };
608+ let changes = match (before, after) {
609+ (Some(before), Some(after)) => {
610+ let mut changes = serde_json::Map::new();
611+ if before.name != after.name {
612+ changes.insert("name".into(), serde_json::json!({ "from": before.name }));
613+ }
614+ if before.body != after.body {
615+ changes.insert("body".into(), serde_json::json!({ "from": before.body }));
616+ }
617+ (!changes.is_empty()).then_some(serde_json::Value::Object(changes))
618+ }
619+ _ => None,
620+ };
621+ for action in release_actions(before.map(state), after.map(state)) {
622+ let Some(kind) = release_kind(action) else { continue };
623+ let data = ReleaseEvent {
624+ release_id: release.id.clone(),
625+ repo_id: repo.id.clone(),
626+ tag_name: release.tag_name.clone(),
627+ release: release.clone(),
628+ changes: if action == "edited" { changes.clone() } else { None },
629+ };
630+ let published = self
631+ .publish(NewEvent {
632+ kind,
633+ source: crate::SOURCE,
634+ repo_id: Some(repo.id.clone()),
635+ actor: Some(actor.id.clone()),
636+ data: g1t_contracts::events::marked(data, Some(actor)),
637+ })
638+ .await;
639+ if let Err(error) = published {
640+ worker::console_error!("{kind} for {} not published: {error}", release.id);
641+ }
642+ }
643+ }
585644 }
586645
587646 /// A refusal of one type, as another: it carries no value.