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.md";
155const NEEDS_REVIEW_PULLS_URL: &str = "https://github.com/zed-industries/zed/pulls?q=is%3Apr+is%3Aclosed+label%3A%22PR+state%3Aneeds+review%22";
156
157pub(crate) enum ComplianceContext {
158 Release,
159 ReleaseNonBlocking,
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 = upload_artifact(COMPLIANCE_REPORT_PATH)
169 .if_condition(Expression::new("always()"))
170 .when(matches!(context, ComplianceContext::Release), |step| {
171 step.add_with(("overwrite", true))
172 });
173
174 let (success_prefix, failure_prefix) = match context {
175 ComplianceContext::Release | ComplianceContext::ReleaseNonBlocking => {
176 ("✅ Compliance check passed", "❌ Compliance check failed")
177 }
178 ComplianceContext::Scheduled { .. } => (
179 "✅ Scheduled compliance check passed",
180 "⚠️ Scheduled compliance check failed",
181 ),
182 };
183
184 let script = formatdoc! {r#"
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" "$STATUS" "$ARTIFACT_URL" "{NEEDS_REVIEW_PULLS_URL}")
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 | ComplianceContext::ReleaseNonBlocking => {
211 Context::github().ref_name().to_string()
212 }
213 ComplianceContext::Scheduled { tag_source } => tag_source.to_string(),
214 },
215 ))
216 .add_env((
217 "ARTIFACT_URL",
218 format!("{CURRENT_ACTION_RUN_URL}#artifacts"),
219 ));
220
221 job.add_step(upload_step).add_step(notification_step)
222}
223
224fn compliance_check() -> NamedJob {
225 fn run_compliance_check() -> Step<Run> {
226 named::bash(formatdoc! {r#"
227 cargo xtask compliance "$GITHUB_REF_NAME" --report-path {COMPLIANCE_REPORT_PATH}
228 "#,
229 })
230 .id("run-compliance-check")
231 .add_env(("GITHUB_APP_ID", vars::ZED_ZIPPY_APP_ID))
232 .add_env(("GITHUB_APP_KEY", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
233 .continue_on_error(true)
234 }
235
236 let job = release_job(&[])
237 .runs_on(runners::LINUX_SMALL)
238 .add_step(
239 steps::checkout_repo()
240 .with_full_history()
241 .with_ref(Context::github().ref_()),
242 )
243 .add_step(steps::cache_rust_dependencies_namespace())
244 .add_step(run_compliance_check());
245
246 named::job(add_compliance_notification_steps(
247 job,
248 ComplianceContext::ReleaseNonBlocking,
249 "run-compliance-check",
250 ))
251}
252
253fn validate_release_assets(deps: &[&NamedJob]) -> NamedJob {
254 let expected_assets: Vec<String> = assets::all().iter().map(|a| format!("\"{a}\"")).collect();
255 let expected_assets_json = format!("[{}]", expected_assets.join(", "));
256
257 let validation_script = formatdoc! {r#"
258 EXPECTED_ASSETS='{expected_assets_json}'
259 TAG="$GITHUB_REF_NAME"
260
261 ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]')
262
263 MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]')
264
265 if [ -n "$MISSING_ASSETS" ]; then
266 echo "Error: The following assets are missing from the release:"
267 echo "$MISSING_ASSETS"
268 exit 1
269 fi
270
271 echo "All expected assets are present in the release."
272 "#,
273 };
274
275 fn run_post_upload_compliance_check() -> Step<Run> {
276 named::bash(formatdoc! {r#"
277 cargo xtask compliance "$GITHUB_REF_NAME" --report-path {COMPLIANCE_REPORT_PATH}
278 "#,
279 })
280 .id("run-post-upload-compliance-check")
281 .add_env(("GITHUB_APP_ID", vars::ZED_ZIPPY_APP_ID))
282 .add_env(("GITHUB_APP_KEY", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
283 }
284
285 let job = dependant_job(deps)
286 .runs_on(runners::LINUX_SMALL)
287 .add_step(named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)))
288 .add_step(
289 steps::checkout_repo()
290 .with_full_history()
291 .with_ref("${{ github.ref }}"),
292 )
293 .add_step(steps::cache_rust_dependencies_namespace())
294 .add_step(run_post_upload_compliance_check());
295
296 named::job(add_compliance_notification_steps(
297 job,
298 ComplianceContext::Release,
299 "run-post-upload-compliance-check",
300 ))
301}
302
303fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob {
304 let (authenticate, token) = steps::authenticate_as_zippy().into();
305
306 named::job(
307 dependant_job(deps)
308 .runs_on(runners::LINUX_SMALL)
309 .cond(Expression::new(indoc::indoc!(
310 r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
311 )))
312 .add_step(authenticate)
313 .add_step(
314 steps::script(
315 r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
316 )
317 .add_env(("GITHUB_TOKEN", &token)),
318 )
319 )
320}
321
322pub(crate) fn download_workflow_artifacts() -> Step<Use> {
323 named::uses(
324 "actions",
325 "download-artifact",
326 "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
327 )
328 .add_with(("path", "./artifacts/"))
329}
330
331pub(crate) fn prep_release_artifacts() -> Step<Run> {
332 let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()];
333 for asset in assets::all() {
334 let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}");
335 script_lines.push(mv_command)
336 }
337
338 named::bash(&script_lines.join("\n"))
339}
340
341fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob {
342 let mut deps = deps.to_vec();
343 deps.extend(bundle.jobs());
344
345 named::job(
346 dependant_job(&deps)
347 .runs_on(runners::LINUX_MEDIUM)
348 .add_step(download_workflow_artifacts())
349 .add_step(steps::script("ls -lR ./artifacts"))
350 .add_step(prep_release_artifacts())
351 .add_step(
352 steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*")
353 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
354 ),
355 )
356}
357
358fn create_draft_release() -> NamedJob {
359 fn generate_release_notes() -> Step<Run> {
360 named::bash(
361 r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#,
362 )
363 }
364
365 fn create_release() -> Step<Run> {
366 named::bash("script/create-draft-release target/release-notes.md")
367 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
368 }
369
370 named::job(
371 release_job(&[])
372 .runs_on(runners::LINUX_SMALL)
373 // We need to fetch more than one commit so that `script/draft-release-notes`
374 // is able to diff between the current and previous tag.
375 //
376 // 25 was chosen arbitrarily.
377 .add_step(
378 steps::checkout_repo()
379 .with_custom_fetch_depth(25)
380 .with_ref(Context::github().ref_()),
381 )
382 .add_step(steps::script("script/determine-release-channel"))
383 .add_step(steps::script("mkdir -p target/"))
384 .add_step(generate_release_notes())
385 .add_step(create_release()),
386 )
387}
388
389pub(crate) fn push_release_update_notification(
390 create_draft_release_job: &NamedJob,
391 upload_assets_job: &NamedJob,
392 validate_assets_job: &NamedJob,
393 auto_release_preview: &NamedJob,
394 test_jobs: &[&NamedJob],
395 bundle_jobs: &ReleaseBundleJobs,
396) -> NamedJob {
397 fn env_name(name: &str) -> String {
398 format!("RESULT_{}", name.to_uppercase())
399 }
400
401 let all_job_names: Vec<&str> = test_jobs
402 .iter()
403 .map(|j| j.name.as_ref())
404 .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()))
405 .collect();
406
407 let env_entries = [
408 (
409 "DRAFT_RESULT".into(),
410 format!("${{{{ needs.{}.result }}}}", create_draft_release_job.name),
411 ),
412 (
413 "UPLOAD_RESULT".into(),
414 format!("${{{{ needs.{}.result }}}}", upload_assets_job.name),
415 ),
416 (
417 "VALIDATE_RESULT".into(),
418 format!("${{{{ needs.{}.result }}}}", validate_assets_job.name),
419 ),
420 (
421 "AUTO_RELEASE_RESULT".into(),
422 format!("${{{{ needs.{}.result }}}}", auto_release_preview.name),
423 ),
424 ("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()),
425 ]
426 .into_iter()
427 .chain(
428 all_job_names
429 .iter()
430 .map(|name| (env_name(name), format!("${{{{ needs.{name}.result }}}}"))),
431 );
432
433 let failure_checks = all_job_names
434 .iter()
435 .map(|name| {
436 format!(
437 "if [ \"${env_name}\" == \"failure\" ];then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi",
438 env_name = env_name(name)
439 )
440 })
441 .collect::<Vec<_>>()
442 .join("\n ");
443
444 let notification_script = formatdoc! {r#"
445 TAG="$GITHUB_REF_NAME"
446
447 if [ "$DRAFT_RESULT" == "failure" ]; then
448 echo "❌ Draft release creation failed for $TAG: $RUN_URL"
449 else
450 RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
451 if [ "$UPLOAD_RESULT" == "failure" ]; then
452 echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
453 elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
454 FAILED_JOBS=""
455 {failure_checks}
456 FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
457 if [ "$UPLOAD_RESULT" == "cancelled" ]; then
458 if [ -n "$FAILED_JOBS" ]; then
459 echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
460 else
461 echo "❌ Release job for $TAG was cancelled: $RUN_URL"
462 fi
463 else
464 if [ -n "$FAILED_JOBS" ]; then
465 echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
466 else
467 echo "❌ Tests for $TAG failed: $RUN_URL"
468 fi
469 fi
470 elif [ "$VALIDATE_RESULT" == "failure" ]; then
471 echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
472 elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
473 echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
474 elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
475 echo "❌ Auto release failed for $TAG: $RUN_URL"
476 else
477 echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
478 fi
479 fi
480 "#,
481 };
482
483 let mut all_deps: Vec<&NamedJob> = vec![
484 create_draft_release_job,
485 upload_assets_job,
486 validate_assets_job,
487 auto_release_preview,
488 ];
489 all_deps.extend(test_jobs.iter().copied());
490 all_deps.extend(bundle_jobs.jobs());
491
492 let mut job = dependant_job(&all_deps)
493 .runs_on(runners::LINUX_SMALL)
494 .cond(Expression::new("always()"));
495
496 for step in notify_slack(MessageType::Evaluated {
497 script: notification_script,
498 env: env_entries.collect(),
499 }) {
500 job = job.add_step(step);
501 }
502 named::job(job)
503}
504
505pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
506 let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
507
508 let mut job = dependant_job(deps)
509 .runs_on(runners::LINUX_SMALL)
510 .cond(Expression::new("failure()"));
511
512 for step in notify_slack(MessageType::Static(failure_message)) {
513 job = job.add_step(step);
514 }
515 named::job(job)
516}
517
518pub(crate) enum MessageType {
519 Static(String),
520 Evaluated {
521 script: String,
522 env: Vec<(String, String)>,
523 },
524}
525
526fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
527 match message {
528 MessageType::Static(message) => vec![send_slack_message(message)],
529 MessageType::Evaluated { script, env } => {
530 let (generate_step, generated_message) = generate_slack_message(script, env);
531
532 vec![
533 generate_step,
534 send_slack_message(generated_message.to_string()),
535 ]
536 }
537 }
538}
539
540fn generate_slack_message(
541 expression: String,
542 env: Vec<(String, String)>,
543) -> (Step<Run>, StepOutput) {
544 let script = formatdoc! {r#"
545 MESSAGE=$({expression})
546 echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
547 "#
548 };
549 let mut generate_step = named::bash(&script)
550 .id("generate-webhook-message")
551 .add_env(("GH_TOKEN", Context::github().token()));
552
553 for (name, value) in env {
554 generate_step = generate_step.add_env((name, value));
555 }
556
557 let output = StepOutput::new(&generate_step, "message");
558
559 (generate_step, output)
560}
561
562fn send_slack_message(message: String) -> Step<Run> {
563 named::bash(
564 r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""#
565 )
566 .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
567 .add_env(("SLACK_MESSAGE", message))
568}