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