release.rs

  1use gh_workflow::{Event, Expression, Job, Push, Run, Step, Use, Workflow, ctx::Context};
  2use indoc::formatdoc;
  3
  4use crate::tasks::workflows::{
  5    run_bundling::{bundle_linux, bundle_mac, bundle_windows},
  6    run_tests,
  7    runners::{self, Arch, Platform},
  8    steps::{
  9        self, CommonJobConditions, FluentBuilder, NamedJob, dependant_job, named, release_job,
 10    },
 11    vars::{self, StepOutput, assets},
 12};
 13
 14const CURRENT_ACTION_RUN_URL: &str =
 15    "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}";
 16
 17pub(crate) fn release() -> Workflow {
 18    let macos_tests = run_tests::run_platform_tests_no_filter(Platform::Mac);
 19    let linux_tests = run_tests::run_platform_tests_no_filter(Platform::Linux);
 20    let windows_tests = run_tests::run_platform_tests_no_filter(Platform::Windows);
 21    let macos_clippy = run_tests::clippy(Platform::Mac, None);
 22    let linux_clippy = run_tests::clippy(Platform::Linux, None);
 23    let windows_clippy = run_tests::clippy(Platform::Windows, None);
 24    let check_scripts = run_tests::check_scripts();
 25
 26    let create_draft_release = create_draft_release();
 27    let compliance = compliance_check();
 28
 29    let bundle = ReleaseBundleJobs {
 30        linux_aarch64: bundle_linux(
 31            Arch::AARCH64,
 32            None,
 33            &[&linux_tests, &linux_clippy, &check_scripts],
 34        ),
 35        linux_x86_64: bundle_linux(
 36            Arch::X86_64,
 37            None,
 38            &[&linux_tests, &linux_clippy, &check_scripts],
 39        ),
 40        mac_aarch64: bundle_mac(
 41            Arch::AARCH64,
 42            None,
 43            &[&macos_tests, &macos_clippy, &check_scripts],
 44        ),
 45        mac_x86_64: bundle_mac(
 46            Arch::X86_64,
 47            None,
 48            &[&macos_tests, &macos_clippy, &check_scripts],
 49        ),
 50        windows_aarch64: bundle_windows(
 51            Arch::AARCH64,
 52            None,
 53            &[&windows_tests, &windows_clippy, &check_scripts],
 54        ),
 55        windows_x86_64: bundle_windows(
 56            Arch::X86_64,
 57            None,
 58            &[&windows_tests, &windows_clippy, &check_scripts],
 59        ),
 60    };
 61
 62    let upload_release_assets = upload_release_assets(&[&create_draft_release], &bundle);
 63    let validate_release_assets = validate_release_assets(&[&upload_release_assets]);
 64
 65    let auto_release_preview = auto_release_preview(&[&validate_release_assets]);
 66
 67    let test_jobs = [
 68        &macos_tests,
 69        &linux_tests,
 70        &windows_tests,
 71        &macos_clippy,
 72        &linux_clippy,
 73        &windows_clippy,
 74        &check_scripts,
 75    ];
 76    let push_slack_notification = push_release_update_notification(
 77        &create_draft_release,
 78        &upload_release_assets,
 79        &validate_release_assets,
 80        &auto_release_preview,
 81        &test_jobs,
 82        &bundle,
 83    );
 84
 85    named::workflow()
 86        .on(Event::default().push(Push::default().tags(vec!["v*".to_string()])))
 87        .concurrency(vars::one_workflow_per_non_main_branch())
 88        .add_env(("CARGO_TERM_COLOR", "always"))
 89        .add_env(("RUST_BACKTRACE", "1"))
 90        .add_job(macos_tests.name, macos_tests.job)
 91        .add_job(linux_tests.name, linux_tests.job)
 92        .add_job(windows_tests.name, windows_tests.job)
 93        .add_job(macos_clippy.name, macos_clippy.job)
 94        .add_job(linux_clippy.name, linux_clippy.job)
 95        .add_job(windows_clippy.name, windows_clippy.job)
 96        .add_job(check_scripts.name, check_scripts.job)
 97        .add_job(create_draft_release.name, create_draft_release.job)
 98        .add_job(compliance.name, compliance.job)
 99        .map(|mut workflow| {
100            for job in bundle.into_jobs() {
101                workflow = workflow.add_job(job.name, job.job);
102            }
103            workflow
104        })
105        .add_job(upload_release_assets.name, upload_release_assets.job)
106        .add_job(validate_release_assets.name, validate_release_assets.job)
107        .add_job(auto_release_preview.name, auto_release_preview.job)
108        .add_job(push_slack_notification.name, push_slack_notification.job)
109}
110
111pub(crate) struct ReleaseBundleJobs {
112    pub linux_aarch64: NamedJob,
113    pub linux_x86_64: NamedJob,
114    pub mac_aarch64: NamedJob,
115    pub mac_x86_64: NamedJob,
116    pub windows_aarch64: NamedJob,
117    pub windows_x86_64: NamedJob,
118}
119
120impl ReleaseBundleJobs {
121    pub fn jobs(&self) -> Vec<&NamedJob> {
122        vec![
123            &self.linux_aarch64,
124            &self.linux_x86_64,
125            &self.mac_aarch64,
126            &self.mac_x86_64,
127            &self.windows_aarch64,
128            &self.windows_x86_64,
129        ]
130    }
131
132    pub fn into_jobs(self) -> Vec<NamedJob> {
133        vec![
134            self.linux_aarch64,
135            self.linux_x86_64,
136            self.mac_aarch64,
137            self.mac_x86_64,
138            self.windows_aarch64,
139            self.windows_x86_64,
140        ]
141    }
142}
143
144pub(crate) fn create_sentry_release() -> Step<Use> {
145    named::uses(
146        "getsentry",
147        "action-release",
148        "526942b68292201ac6bbb99b9a0747d4abee354c", // v3
149    )
150    .add_env(("SENTRY_ORG", "zed-dev"))
151    .add_env(("SENTRY_PROJECT", "zed"))
152    .add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN))
153    .add_with(("environment", "production"))
154}
155
156fn compliance_check() -> NamedJob {
157    fn run_compliance_check() -> Step<Run> {
158        named::bash(
159            r#"cargo xtask compliance "$GITHUB_REF_NAME" --report-path "$COMPLIANCE_FILE_OUTPUT""#,
160        )
161        .id("run-compliance-check")
162        .add_env(("GITHUB_APP_ID", vars::ZED_ZIPPY_APP_ID))
163        .add_env(("GITHUB_APP_KEY", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
164    }
165
166    fn send_compliance_slack_notification() -> Step<Run> {
167        named::bash(indoc::indoc! {r#"
168            if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
169                STATUS="✅ Compliance check passed for $GITHUB_REF_NAME"
170            else
171                STATUS="❌ Compliance check failed for $GITHUB_REF_NAME"
172            fi
173
174            REPORT_CONTENT=""
175            if [ -f "$COMPLIANCE_FILE_OUTPUT" ]; then
176                REPORT_CONTENT=$(cat "$REPORT_FILE")
177            fi
178
179            MESSAGE=$(printf "%s\n\n%s" "$STATUS" "$REPORT_CONTENT")
180
181            curl -X POST -H 'Content-type: application/json' \
182                --data "$(jq -n --arg text "$MESSAGE" '{"text": $text}')" \
183                "$SLACK_WEBHOOK"
184        "#})
185        .if_condition(Expression::new("always()"))
186        .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
187        .add_env((
188            "COMPLIANCE_OUTCOME",
189            "${{ steps.run-compliance-check.outcome }}",
190        ))
191    }
192
193    named::job(
194        Job::default()
195            .add_env(("COMPLIANCE_FILE_PATH", "compliance.md"))
196            .with_repository_owner_guard()
197            .runs_on(runners::LINUX_DEFAULT)
198            .add_step(
199                steps::checkout_repo()
200                    .with_full_history()
201                    .with_ref(Context::github().ref_()),
202            )
203            .add_step(steps::cache_rust_dependencies_namespace())
204            .add_step(run_compliance_check())
205            .add_step(send_compliance_slack_notification()),
206    )
207}
208
209fn validate_release_assets(deps: &[&NamedJob]) -> NamedJob {
210    let expected_assets: Vec<String> = assets::all().iter().map(|a| format!("\"{a}\"")).collect();
211    let expected_assets_json = format!("[{}]", expected_assets.join(", "));
212
213    let validation_script = formatdoc! {r#"
214        EXPECTED_ASSETS='{expected_assets_json}'
215        TAG="$GITHUB_REF_NAME"
216
217        ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]')
218
219        MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]')
220
221        if [ -n "$MISSING_ASSETS" ]; then
222            echo "Error: The following assets are missing from the release:"
223            echo "$MISSING_ASSETS"
224            exit 1
225        fi
226
227        echo "All expected assets are present in the release."
228        "#,
229    };
230
231    fn run_post_upload_compliance_check() -> Step<Run> {
232        named::bash(
233            r#"cargo xtask compliance "$GITHUB_REF_NAME" --report-path target/compliance-report"#,
234        )
235        .id("run-post-upload-compliance-check")
236        .add_env(("GITHUB_APP_ID", vars::ZED_ZIPPY_APP_ID))
237        .add_env(("GITHUB_APP_KEY", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
238    }
239
240    fn send_post_upload_compliance_notification() -> Step<Run> {
241        named::bash(indoc::indoc! {r#"
242            if [ -z "$COMPLIANCE_OUTCOME" ] || [ "$COMPLIANCE_OUTCOME" == "skipped" ]; then
243                echo "Compliance check was skipped, not sending notification"
244                exit 0
245            fi
246
247            TAG="$GITHUB_REF_NAME"
248
249            if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
250                MESSAGE="✅ Post-upload compliance re-check passed for $TAG"
251            else
252                MESSAGE="❌ Post-upload compliance re-check failed for $TAG"
253            fi
254
255            curl -X POST -H 'Content-type: application/json' \
256                --data "$(jq -n --arg text "$MESSAGE" '{"text": $text}')" \
257                "$SLACK_WEBHOOK"
258        "#})
259        .if_condition(Expression::new("always()"))
260        .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
261        .add_env((
262            "COMPLIANCE_OUTCOME",
263            "${{ steps.run-post-upload-compliance-check.outcome }}",
264        ))
265    }
266
267    named::job(
268        dependant_job(deps)
269            .runs_on(runners::LINUX_SMALL)
270            .add_step(named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)))
271            .add_step(
272                steps::checkout_repo()
273                    .with_full_history()
274                    .with_ref(Context::github().ref_()),
275            )
276            .add_step(steps::cache_rust_dependencies_namespace())
277            .add_step(run_post_upload_compliance_check())
278            .add_step(send_post_upload_compliance_notification()),
279    )
280}
281
282fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob {
283    let (authenticate, token) = steps::authenticate_as_zippy().into();
284
285    named::job(
286        dependant_job(deps)
287            .runs_on(runners::LINUX_SMALL)
288            .cond(Expression::new(indoc::indoc!(
289                r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
290            )))
291            .add_step(authenticate)
292            .add_step(
293                steps::script(
294                    r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
295                )
296                .add_env(("GITHUB_TOKEN", &token)),
297            )
298    )
299}
300
301pub(crate) fn download_workflow_artifacts() -> Step<Use> {
302    named::uses(
303        "actions",
304        "download-artifact",
305        "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
306    )
307    .add_with(("path", "./artifacts/"))
308}
309
310pub(crate) fn prep_release_artifacts() -> Step<Run> {
311    let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()];
312    for asset in assets::all() {
313        let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}");
314        script_lines.push(mv_command)
315    }
316
317    named::bash(&script_lines.join("\n"))
318}
319
320fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob {
321    let mut deps = deps.to_vec();
322    deps.extend(bundle.jobs());
323
324    named::job(
325        dependant_job(&deps)
326            .runs_on(runners::LINUX_MEDIUM)
327            .add_step(download_workflow_artifacts())
328            .add_step(steps::script("ls -lR ./artifacts"))
329            .add_step(prep_release_artifacts())
330            .add_step(
331                steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*")
332                    .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
333            ),
334    )
335}
336
337fn create_draft_release() -> NamedJob {
338    fn generate_release_notes() -> Step<Run> {
339        named::bash(
340            r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#,
341        )
342    }
343
344    fn create_release() -> Step<Run> {
345        named::bash("script/create-draft-release target/release-notes.md")
346            .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
347    }
348
349    named::job(
350        release_job(&[])
351            .runs_on(runners::LINUX_SMALL)
352            // We need to fetch more than one commit so that `script/draft-release-notes`
353            // is able to diff between the current and previous tag.
354            //
355            // 25 was chosen arbitrarily.
356            .add_step(
357                steps::checkout_repo()
358                    .with_custom_fetch_depth(25)
359                    .with_ref(Context::github().ref_()),
360            )
361            .add_step(steps::script("script/determine-release-channel"))
362            .add_step(steps::script("mkdir -p target/"))
363            .add_step(generate_release_notes())
364            .add_step(create_release()),
365    )
366}
367
368pub(crate) fn push_release_update_notification(
369    create_draft_release_job: &NamedJob,
370    upload_assets_job: &NamedJob,
371    validate_assets_job: &NamedJob,
372    auto_release_preview: &NamedJob,
373    test_jobs: &[&NamedJob],
374    bundle_jobs: &ReleaseBundleJobs,
375) -> NamedJob {
376    fn env_name(name: &str) -> String {
377        format!("RESULT_{}", name.to_uppercase())
378    }
379
380    let all_job_names: Vec<&str> = test_jobs
381        .iter()
382        .map(|j| j.name.as_ref())
383        .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()))
384        .collect();
385
386    let env_entries = [
387        (
388            "DRAFT_RESULT".into(),
389            format!("${{{{ needs.{}.result }}}}", create_draft_release_job.name),
390        ),
391        (
392            "UPLOAD_RESULT".into(),
393            format!("${{{{ needs.{}.result }}}}", upload_assets_job.name),
394        ),
395        (
396            "VALIDATE_RESULT".into(),
397            format!("${{{{ needs.{}.result }}}}", validate_assets_job.name),
398        ),
399        (
400            "AUTO_RELEASE_RESULT".into(),
401            format!("${{{{ needs.{}.result }}}}", auto_release_preview.name),
402        ),
403        ("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()),
404    ]
405    .into_iter()
406    .chain(
407        all_job_names
408            .iter()
409            .map(|name| (env_name(name), format!("${{{{ needs.{name}.result }}}}"))),
410    );
411
412    let failure_checks = all_job_names
413        .iter()
414        .map(|name| {
415            format!(
416                "if [ \"${env_name}\" == \"failure\" ];then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi",
417                    env_name = env_name(name)
418            )
419        })
420        .collect::<Vec<_>>()
421        .join("\n        ");
422
423    let notification_script = formatdoc! {r#"
424        TAG="$GITHUB_REF_NAME"
425
426        if [ "$DRAFT_RESULT" == "failure" ]; then
427            echo "❌ Draft release creation failed for $TAG: $RUN_URL"
428        else
429            RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
430            if [ "$UPLOAD_RESULT" == "failure" ]; then
431                echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
432            elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
433                FAILED_JOBS=""
434                {failure_checks}
435                FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
436                if [ "$UPLOAD_RESULT" == "cancelled" ]; then
437                    if [ -n "$FAILED_JOBS" ]; then
438                        echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
439                    else
440                        echo "❌ Release job for $TAG was cancelled: $RUN_URL"
441                    fi
442                else
443                    if [ -n "$FAILED_JOBS" ]; then
444                        echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
445                    else
446                        echo "❌ Tests for $TAG failed: $RUN_URL"
447                    fi
448                fi
449            elif [ "$VALIDATE_RESULT" == "failure" ]; then
450                echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
451            elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
452                echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
453            elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
454                echo "❌ Auto release failed for $TAG: $RUN_URL"
455            else
456                echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
457            fi
458        fi
459        "#,
460    };
461
462    let mut all_deps: Vec<&NamedJob> = vec![
463        create_draft_release_job,
464        upload_assets_job,
465        validate_assets_job,
466        auto_release_preview,
467    ];
468    all_deps.extend(test_jobs.iter().copied());
469    all_deps.extend(bundle_jobs.jobs());
470
471    let mut job = dependant_job(&all_deps)
472        .runs_on(runners::LINUX_SMALL)
473        .cond(Expression::new("always()"));
474
475    for step in notify_slack(MessageType::Evaluated {
476        script: notification_script,
477        env: env_entries.collect(),
478    }) {
479        job = job.add_step(step);
480    }
481    named::job(job)
482}
483
484pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
485    let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
486
487    let mut job = dependant_job(deps)
488        .runs_on(runners::LINUX_SMALL)
489        .cond(Expression::new("failure()"));
490
491    for step in notify_slack(MessageType::Static(failure_message)) {
492        job = job.add_step(step);
493    }
494    named::job(job)
495}
496
497pub(crate) enum MessageType {
498    Static(String),
499    Evaluated {
500        script: String,
501        env: Vec<(String, String)>,
502    },
503}
504
505fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
506    match message {
507        MessageType::Static(message) => vec![send_slack_message(message)],
508        MessageType::Evaluated { script, env } => {
509            let (generate_step, generated_message) = generate_slack_message(script, env);
510
511            vec![
512                generate_step,
513                send_slack_message(generated_message.to_string()),
514            ]
515        }
516    }
517}
518
519fn generate_slack_message(
520    expression: String,
521    env: Vec<(String, String)>,
522) -> (Step<Run>, StepOutput) {
523    let script = formatdoc! {r#"
524        MESSAGE=$({expression})
525        echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
526        "#
527    };
528    let mut generate_step = named::bash(&script)
529        .id("generate-webhook-message")
530        .add_env(("GH_TOKEN", Context::github().token()));
531
532    for (name, value) in env {
533        generate_step = generate_step.add_env((name, value));
534    }
535
536    let output = StepOutput::new(&generate_step, "message");
537
538    (generate_step, output)
539}
540
541fn send_slack_message(message: String) -> Step<Run> {
542    named::bash(
543        r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""#
544    )
545    .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
546    .add_env(("SLACK_MESSAGE", message))
547}