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}