release.rs

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