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}