1use gh_workflow::{
2 Event, Expression, Input, Job, Level, Permissions, Push, Run, Step, Use, UsesJob, Workflow,
3 ctx::Context,
4};
5use indoc::formatdoc;
6
7use crate::tasks::workflows::{
8 run_bundling::{bundle_linux, bundle_mac, bundle_windows, upload_artifact},
9 run_tests,
10 runners::{self, Arch, Platform},
11 steps::{self, FluentBuilder, NamedJob, dependant_job, named, release_job},
12 vars::{self, JobOutput, StepOutput, assets},
13};
14
15const CURRENT_ACTION_RUN_URL: &str =
16 "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}";
17
18pub(crate) fn release() -> Workflow {
19 let macos_tests = run_tests::run_platform_tests_no_filter(Platform::Mac);
20 let linux_tests = run_tests::run_platform_tests_no_filter(Platform::Linux);
21 let windows_tests = run_tests::run_platform_tests_no_filter(Platform::Windows);
22 let macos_clippy = run_tests::clippy(Platform::Mac, None);
23 let linux_clippy = run_tests::clippy(Platform::Linux, None);
24 let windows_clippy = run_tests::clippy(Platform::Windows, None);
25 let check_scripts = run_tests::check_scripts();
26
27 let create_draft_release = create_draft_release();
28 let (non_blocking_compliance_run, job_output) = compliance_check();
29
30 let bundle = ReleaseBundleJobs {
31 linux_aarch64: bundle_linux(
32 Arch::AARCH64,
33 None,
34 &[&linux_tests, &linux_clippy, &check_scripts],
35 ),
36 linux_x86_64: bundle_linux(
37 Arch::X86_64,
38 None,
39 &[&linux_tests, &linux_clippy, &check_scripts],
40 ),
41 mac_aarch64: bundle_mac(
42 Arch::AARCH64,
43 None,
44 &[&macos_tests, &macos_clippy, &check_scripts],
45 ),
46 mac_x86_64: bundle_mac(
47 Arch::X86_64,
48 None,
49 &[&macos_tests, &macos_clippy, &check_scripts],
50 ),
51 windows_aarch64: bundle_windows(
52 Arch::AARCH64,
53 None,
54 &[&windows_tests, &windows_clippy, &check_scripts],
55 ),
56 windows_x86_64: bundle_windows(
57 Arch::X86_64,
58 None,
59 &[&windows_tests, &windows_clippy, &check_scripts],
60 ),
61 };
62
63 let upload_release_assets = upload_release_assets(&[&create_draft_release], &bundle);
64 let validate_release_assets = validate_release_assets(
65 &[&upload_release_assets, &non_blocking_compliance_run],
66 job_output,
67 );
68
69 let auto_release_preview = auto_release_preview(&[&validate_release_assets]);
70 let deploy_docs = deploy_docs(&[&validate_release_assets], &create_draft_release);
71
72 let test_jobs = [
73 &macos_tests,
74 &linux_tests,
75 &windows_tests,
76 &macos_clippy,
77 &linux_clippy,
78 &windows_clippy,
79 &check_scripts,
80 ];
81 let push_slack_notification = push_release_update_notification(
82 &create_draft_release,
83 &upload_release_assets,
84 &validate_release_assets,
85 &auto_release_preview,
86 &test_jobs,
87 &bundle,
88 );
89
90 named::workflow()
91 .on(Event::default().push(Push::default().tags(vec!["v*".to_string()])))
92 .concurrency(vars::one_workflow_per_non_main_branch())
93 .add_env(("CARGO_TERM_COLOR", "always"))
94 .add_env(("RUST_BACKTRACE", "1"))
95 .add_job(macos_tests.name, macos_tests.job)
96 .add_job(linux_tests.name, linux_tests.job)
97 .add_job(windows_tests.name, windows_tests.job)
98 .add_job(macos_clippy.name, macos_clippy.job)
99 .add_job(linux_clippy.name, linux_clippy.job)
100 .add_job(windows_clippy.name, windows_clippy.job)
101 .add_job(check_scripts.name, check_scripts.job)
102 .add_job(create_draft_release.name, create_draft_release.job)
103 .add_job(
104 non_blocking_compliance_run.name,
105 non_blocking_compliance_run.job,
106 )
107 .map(|mut workflow| {
108 for job in bundle.into_jobs() {
109 workflow = workflow.add_job(job.name, job.job);
110 }
111 workflow
112 })
113 .add_job(upload_release_assets.name, upload_release_assets.job)
114 .add_job(validate_release_assets.name, validate_release_assets.job)
115 .add_job(auto_release_preview.name, auto_release_preview.job)
116 .add_job(deploy_docs.name, deploy_docs.job)
117 .add_job(push_slack_notification.name, push_slack_notification.job)
118}
119
120pub(crate) struct ReleaseBundleJobs {
121 pub linux_aarch64: NamedJob,
122 pub linux_x86_64: NamedJob,
123 pub mac_aarch64: NamedJob,
124 pub mac_x86_64: NamedJob,
125 pub windows_aarch64: NamedJob,
126 pub windows_x86_64: NamedJob,
127}
128
129impl ReleaseBundleJobs {
130 pub fn jobs(&self) -> Vec<&NamedJob> {
131 vec![
132 &self.linux_aarch64,
133 &self.linux_x86_64,
134 &self.mac_aarch64,
135 &self.mac_x86_64,
136 &self.windows_aarch64,
137 &self.windows_x86_64,
138 ]
139 }
140
141 pub fn into_jobs(self) -> Vec<NamedJob> {
142 vec![
143 self.linux_aarch64,
144 self.linux_x86_64,
145 self.mac_aarch64,
146 self.mac_x86_64,
147 self.windows_aarch64,
148 self.windows_x86_64,
149 ]
150 }
151}
152
153pub(crate) fn create_sentry_release() -> Step<Use> {
154 named::uses(
155 "getsentry",
156 "action-release",
157 "526942b68292201ac6bbb99b9a0747d4abee354c", // v3
158 )
159 .add_env(("SENTRY_ORG", "zed-dev"))
160 .add_env(("SENTRY_PROJECT", "zed"))
161 .add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN))
162 .add_with(("environment", "production"))
163}
164
165pub(crate) const COMPLIANCE_REPORT_PATH: &str = "compliance-report-${GITHUB_REF_NAME}.md";
166pub(crate) const COMPLIANCE_REPORT_ARTIFACT_PATH: &str =
167 "compliance-report-${{ github.ref_name }}.md";
168pub(crate) const COMPLIANCE_STEP_ID: &str = "run-compliance-check";
169const NEEDS_REVIEW_PULLS_URL: &str = "https://github.com/zed-industries/zed/pulls?q=is%3Apr+is%3Aclosed+label%3A%22PR+state%3Aneeds+review%22";
170
171pub(crate) enum ComplianceContext {
172 Release { non_blocking_outcome: JobOutput },
173 ReleaseNonBlocking,
174 Scheduled { tag_source: StepOutput },
175}
176
177impl ComplianceContext {
178 fn tag_source(&self) -> Option<&StepOutput> {
179 match self {
180 ComplianceContext::Scheduled { tag_source } => Some(tag_source),
181 _ => None,
182 }
183 }
184}
185
186pub(crate) fn add_compliance_steps(
187 job: gh_workflow::Job,
188 context: ComplianceContext,
189) -> (gh_workflow::Job, StepOutput) {
190 fn run_compliance_check(context: &ComplianceContext) -> (Step<Run>, StepOutput) {
191 let job = named::bash(
192 formatdoc! {r#"
193 cargo xtask compliance version {target} --report-path "{COMPLIANCE_REPORT_PATH}"
194 "#,
195 target = if context.tag_source().is_some() { r#""$LATEST_TAG" --branch main"# } else { r#""$GITHUB_REF_NAME""# },
196 }
197 )
198 .id(COMPLIANCE_STEP_ID)
199 .add_env(("GITHUB_APP_ID", vars::ZED_ZIPPY_APP_ID))
200 .add_env(("GITHUB_APP_KEY", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
201 .when_some(context.tag_source(), |step, tag_source| {
202 step.add_env(("LATEST_TAG", tag_source.to_string()))
203 })
204 .when(
205 matches!(
206 context,
207 ComplianceContext::Scheduled { .. } | ComplianceContext::ReleaseNonBlocking
208 ),
209 |step| step.continue_on_error(true),
210 );
211
212 let result = StepOutput::new_unchecked(&job, "outcome");
213 (job, result)
214 }
215
216 let upload_step = upload_artifact(COMPLIANCE_REPORT_ARTIFACT_PATH)
217 .if_condition(Expression::new("always()"))
218 .when(
219 matches!(context, ComplianceContext::Release { .. }),
220 |step| step.add_with(("overwrite", true)),
221 );
222
223 let (success_prefix, failure_prefix) = match context {
224 ComplianceContext::Release { .. } => {
225 ("✅ Compliance check passed", "❌ Compliance check failed")
226 }
227 ComplianceContext::ReleaseNonBlocking => (
228 "✅ Compliance check passed",
229 "❌ Preliminary compliance check failed (but this can still be fixed while the builds are running!)",
230 ),
231 ComplianceContext::Scheduled { .. } => (
232 "✅ Scheduled compliance check passed",
233 "⚠️ Scheduled compliance check failed",
234 ),
235 };
236
237 let script = formatdoc! {r#"
238 if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
239 STATUS="{success_prefix} for $COMPLIANCE_TAG"
240 MESSAGE=$(printf "%s\n\nReport: %s" "$STATUS" "$ARTIFACT_URL")
241 else
242 STATUS="{failure_prefix} for $COMPLIANCE_TAG"
243 MESSAGE=$(printf "%s\n\nReport: %s\nPRs needing review: %s" "$STATUS" "$ARTIFACT_URL" "{NEEDS_REVIEW_PULLS_URL}")
244 fi
245
246 curl -X POST -H 'Content-type: application/json' \
247 --data "$(jq -n --arg text "$MESSAGE" '{{"text": $text}}')" \
248 "$SLACK_WEBHOOK"
249 "#,
250 };
251
252 let notification_step = Step::new("send_compliance_slack_notification")
253 .run(&script)
254 .if_condition(match &context {
255 ComplianceContext::Release {
256 non_blocking_outcome,
257 } => Expression::new(format!(
258 "failure() || {prior_outcome} != 'success'",
259 prior_outcome = non_blocking_outcome.expr()
260 )),
261 ComplianceContext::Scheduled { .. } | ComplianceContext::ReleaseNonBlocking => {
262 Expression::new("always()")
263 }
264 })
265 .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
266 .add_env((
267 "COMPLIANCE_OUTCOME",
268 format!("${{{{ steps.{COMPLIANCE_STEP_ID}.outcome }}}}"),
269 ))
270 .add_env((
271 "COMPLIANCE_TAG",
272 match &context {
273 ComplianceContext::Release { .. } | ComplianceContext::ReleaseNonBlocking => {
274 Context::github().ref_name().to_string()
275 }
276 ComplianceContext::Scheduled { tag_source } => tag_source.to_string(),
277 },
278 ))
279 .add_env((
280 "ARTIFACT_URL",
281 format!("{CURRENT_ACTION_RUN_URL}#artifacts"),
282 ));
283
284 let (compliance_step, check_result) = run_compliance_check(&context);
285
286 (
287 job.add_step(compliance_step)
288 .add_step(upload_step)
289 .add_step(notification_step)
290 .when(
291 matches!(context, ComplianceContext::ReleaseNonBlocking),
292 |step| step.outputs([("outcome".to_string(), check_result.to_string())]),
293 ),
294 check_result,
295 )
296}
297
298fn compliance_check() -> (NamedJob, JobOutput) {
299 let job = release_job(&[])
300 .runs_on(runners::LINUX_SMALL)
301 .add_step(
302 steps::checkout_repo()
303 .with_full_history()
304 .with_ref(Context::github().ref_()),
305 )
306 .add_step(steps::cache_rust_dependencies_namespace());
307
308 let (compliance_job, check_result) =
309 add_compliance_steps(job, ComplianceContext::ReleaseNonBlocking);
310 let compliance_job = named::job(compliance_job);
311 let check_result = check_result.as_job_output(&compliance_job);
312
313 (compliance_job, check_result)
314}
315
316fn validate_release_assets(deps: &[&NamedJob], context_check_result: JobOutput) -> NamedJob {
317 let expected_assets: Vec<String> = assets::all().iter().map(|a| format!("\"{a}\"")).collect();
318 let expected_assets_json = format!("[{}]", expected_assets.join(", "));
319
320 let validation_script = formatdoc! {r#"
321 EXPECTED_ASSETS='{expected_assets_json}'
322 TAG="$GITHUB_REF_NAME"
323
324 ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]')
325
326 MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]')
327
328 if [ -n "$MISSING_ASSETS" ]; then
329 echo "Error: The following assets are missing from the release:"
330 echo "$MISSING_ASSETS"
331 exit 1
332 fi
333
334 echo "All expected assets are present in the release."
335 "#,
336 };
337
338 let job = dependant_job(deps)
339 .runs_on(runners::LINUX_SMALL)
340 .add_step(named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)))
341 .add_step(
342 steps::checkout_repo()
343 .with_full_history()
344 .with_ref(Context::github().ref_()),
345 )
346 .add_step(steps::cache_rust_dependencies_namespace());
347
348 named::job(
349 add_compliance_steps(
350 job,
351 ComplianceContext::Release {
352 non_blocking_outcome: context_check_result,
353 },
354 )
355 .0,
356 )
357}
358
359fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob {
360 let (authenticate, token) = steps::authenticate_as_zippy().into();
361
362 named::job(
363 dependant_job(deps)
364 .runs_on(runners::LINUX_SMALL)
365 .cond(Expression::new(indoc::indoc!(
366 r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
367 )))
368 .add_step(authenticate)
369 .add_step(
370 steps::script(
371 r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
372 )
373 .add_env(("GITHUB_TOKEN", &token)),
374 )
375 )
376}
377
378fn deploy_docs(deps: &[&NamedJob], create_draft_release: &NamedJob) -> NamedJob<UsesJob> {
379 let job = Job::default()
380 .needs(
381 deps.iter()
382 .map(|job| job.name.clone())
383 .chain(std::iter::once(create_draft_release.name.clone()))
384 .collect::<Vec<_>>(),
385 )
386 .permissions(Permissions::default().contents(Level::Read))
387 .uses(
388 "zed-industries",
389 "zed",
390 ".github/workflows/deploy_docs.yml",
391 "main",
392 )
393 .with(
394 Input::default()
395 .add(
396 "channel",
397 "${{ endsWith(github.ref_name, '-pre') && 'preview' || 'stable' }}",
398 )
399 .add("commit_sha", "${{ github.sha }}"),
400 )
401 .secrets(indexmap::IndexMap::from([
402 (
403 "DOCS_AMPLITUDE_API_KEY".to_owned(),
404 vars::DOCS_AMPLITUDE_API_KEY.to_owned(),
405 ),
406 (
407 "CLOUDFLARE_API_TOKEN".to_owned(),
408 vars::CLOUDFLARE_API_TOKEN.to_owned(),
409 ),
410 (
411 "CLOUDFLARE_ACCOUNT_ID".to_owned(),
412 vars::CLOUDFLARE_ACCOUNT_ID.to_owned(),
413 ),
414 ]));
415
416 NamedJob {
417 name: "deploy_docs".to_owned(),
418 job,
419 }
420}
421
422pub(crate) fn download_workflow_artifacts() -> Step<Use> {
423 named::uses(
424 "actions",
425 "download-artifact",
426 "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
427 )
428 .add_with(("path", "./artifacts/"))
429}
430
431pub(crate) fn prep_release_artifacts() -> Step<Run> {
432 let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()];
433 for asset in assets::all() {
434 let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}");
435 script_lines.push(mv_command)
436 }
437
438 named::bash(&script_lines.join("\n"))
439}
440
441fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob {
442 let mut deps = deps.to_vec();
443 deps.extend(bundle.jobs());
444
445 named::job(
446 dependant_job(&deps)
447 .runs_on(runners::LINUX_MEDIUM)
448 .add_step(download_workflow_artifacts())
449 .add_step(steps::script("ls -lR ./artifacts"))
450 .add_step(prep_release_artifacts())
451 .add_step(
452 steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*")
453 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
454 ),
455 )
456}
457
458fn create_draft_release() -> NamedJob {
459 fn generate_release_notes() -> Step<Run> {
460 named::bash(
461 r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#,
462 )
463 }
464
465 fn create_release() -> Step<Run> {
466 named::bash("script/create-draft-release target/release-notes.md")
467 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
468 }
469
470 named::job(
471 release_job(&[])
472 .runs_on(runners::LINUX_SMALL)
473 // We need to fetch more than one commit so that `script/draft-release-notes`
474 // is able to diff between the current and previous tag.
475 //
476 // 25 was chosen arbitrarily.
477 .add_step(
478 steps::checkout_repo()
479 .with_custom_fetch_depth(25)
480 .with_ref(Context::github().ref_()),
481 )
482 .add_step(steps::script("script/determine-release-channel"))
483 .add_step(steps::script("mkdir -p target/"))
484 .add_step(generate_release_notes())
485 .add_step(create_release()),
486 )
487}
488
489pub(crate) fn push_release_update_notification(
490 create_draft_release_job: &NamedJob,
491 upload_assets_job: &NamedJob,
492 validate_assets_job: &NamedJob,
493 auto_release_preview: &NamedJob,
494 test_jobs: &[&NamedJob],
495 bundle_jobs: &ReleaseBundleJobs,
496) -> NamedJob {
497 fn env_name(name: &str) -> String {
498 format!("RESULT_{}", name.to_uppercase())
499 }
500
501 let all_job_names: Vec<&str> = test_jobs
502 .iter()
503 .map(|j| j.name.as_ref())
504 .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()))
505 .collect();
506
507 let env_entries = [
508 (
509 "DRAFT_RESULT".into(),
510 format!("${{{{ needs.{}.result }}}}", create_draft_release_job.name),
511 ),
512 (
513 "UPLOAD_RESULT".into(),
514 format!("${{{{ needs.{}.result }}}}", upload_assets_job.name),
515 ),
516 (
517 "VALIDATE_RESULT".into(),
518 format!("${{{{ needs.{}.result }}}}", validate_assets_job.name),
519 ),
520 (
521 "AUTO_RELEASE_RESULT".into(),
522 format!("${{{{ needs.{}.result }}}}", auto_release_preview.name),
523 ),
524 ("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()),
525 ]
526 .into_iter()
527 .chain(
528 all_job_names
529 .iter()
530 .map(|name| (env_name(name), format!("${{{{ needs.{name}.result }}}}"))),
531 );
532
533 let failure_checks = all_job_names
534 .iter()
535 .map(|name| {
536 format!(
537 "if [ \"${env_name}\" == \"failure\" ];then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi",
538 env_name = env_name(name)
539 )
540 })
541 .collect::<Vec<_>>()
542 .join("\n ");
543
544 let notification_script = formatdoc! {r#"
545 TAG="$GITHUB_REF_NAME"
546
547 if [ "$DRAFT_RESULT" == "failure" ]; then
548 echo "❌ Draft release creation failed for $TAG: $RUN_URL"
549 else
550 RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
551 if [ "$UPLOAD_RESULT" == "failure" ]; then
552 echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
553 elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
554 FAILED_JOBS=""
555 {failure_checks}
556 FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
557 if [ "$UPLOAD_RESULT" == "cancelled" ]; then
558 if [ -n "$FAILED_JOBS" ]; then
559 echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
560 else
561 echo "❌ Release job for $TAG was cancelled: $RUN_URL"
562 fi
563 else
564 if [ -n "$FAILED_JOBS" ]; then
565 echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
566 else
567 echo "❌ Tests for $TAG failed: $RUN_URL"
568 fi
569 fi
570 elif [ "$VALIDATE_RESULT" == "failure" ]; then
571 echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
572 elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
573 echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
574 elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
575 echo "❌ Auto release failed for $TAG: $RUN_URL"
576 else
577 echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
578 fi
579 fi
580 "#,
581 };
582
583 let mut all_deps: Vec<&NamedJob> = vec![
584 create_draft_release_job,
585 upload_assets_job,
586 validate_assets_job,
587 auto_release_preview,
588 ];
589 all_deps.extend(test_jobs.iter().copied());
590 all_deps.extend(bundle_jobs.jobs());
591
592 let mut job = dependant_job(&all_deps)
593 .runs_on(runners::LINUX_SMALL)
594 .cond(Expression::new("always()"));
595
596 for step in notify_slack(MessageType::Evaluated {
597 script: notification_script,
598 env: env_entries.collect(),
599 }) {
600 job = job.add_step(step);
601 }
602 named::job(job)
603}
604
605pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
606 let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
607
608 let mut job = dependant_job(deps)
609 .runs_on(runners::LINUX_SMALL)
610 .cond(Expression::new("failure()"));
611
612 for step in notify_slack(MessageType::Static(failure_message)) {
613 job = job.add_step(step);
614 }
615 named::job(job)
616}
617
618pub(crate) enum MessageType {
619 Static(String),
620 Evaluated {
621 script: String,
622 env: Vec<(String, String)>,
623 },
624}
625
626fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
627 match message {
628 MessageType::Static(message) => vec![send_slack_message(message)],
629 MessageType::Evaluated { script, env } => {
630 let (generate_step, generated_message) = generate_slack_message(script, env);
631
632 vec![
633 generate_step,
634 send_slack_message(generated_message.to_string()),
635 ]
636 }
637 }
638}
639
640fn generate_slack_message(
641 expression: String,
642 env: Vec<(String, String)>,
643) -> (Step<Run>, StepOutput) {
644 let script = formatdoc! {r#"
645 MESSAGE=$({expression})
646 echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
647 "#
648 };
649 let mut generate_step = named::bash(&script)
650 .id("generate-webhook-message")
651 .add_env(("GH_TOKEN", Context::github().token()));
652
653 for (name, value) in env {
654 generate_step = generate_step.add_env((name, value));
655 }
656
657 let output = StepOutput::new(&generate_step, "message");
658
659 (generate_step, output)
660}
661
662fn send_slack_message(message: String) -> Step<Run> {
663 named::bash(
664 r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""#
665 )
666 .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
667 .add_env(("SLACK_MESSAGE", message))
668}