release.rs

  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}