1use gh_workflow::{
2 Event, Expression, Input, Job, Level, Permissions, Push, Run, Step, Use, UsesJob, Workflow,
3 ctx::Context,
4};
5use indoc::formatdoc;
6
7use crate::tasks::workflows::{
8 run_bundling::{bundle_linux, bundle_mac, bundle_windows},
9 run_tests,
10 runners::{self, Arch, Platform},
11 steps::{self, FluentBuilder, NamedJob, dependant_job, named, release_job},
12 vars::{self, StepOutput, assets},
13};
14
15const CURRENT_ACTION_RUN_URL: &str =
16 "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}";
17
18pub(crate) fn release() -> Workflow {
19 let macos_tests = run_tests::run_platform_tests_no_filter(Platform::Mac);
20 let linux_tests = run_tests::run_platform_tests_no_filter(Platform::Linux);
21 let windows_tests = run_tests::run_platform_tests_no_filter(Platform::Windows);
22 let macos_clippy = run_tests::clippy(Platform::Mac);
23 let linux_clippy = run_tests::clippy(Platform::Linux);
24 let windows_clippy = run_tests::clippy(Platform::Windows);
25 let check_scripts = run_tests::check_scripts();
26
27 let create_draft_release = create_draft_release();
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 let deploy_docs = deploy_docs(&[&validate_release_assets], &create_draft_release);
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 .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(deploy_docs.name, deploy_docs.job)
109 .add_job(push_slack_notification.name, push_slack_notification.job)
110}
111
112pub(crate) struct ReleaseBundleJobs {
113 pub linux_aarch64: NamedJob,
114 pub linux_x86_64: NamedJob,
115 pub mac_aarch64: NamedJob,
116 pub mac_x86_64: NamedJob,
117 pub windows_aarch64: NamedJob,
118 pub windows_x86_64: NamedJob,
119}
120
121impl ReleaseBundleJobs {
122 pub fn jobs(&self) -> Vec<&NamedJob> {
123 vec![
124 &self.linux_aarch64,
125 &self.linux_x86_64,
126 &self.mac_aarch64,
127 &self.mac_x86_64,
128 &self.windows_aarch64,
129 &self.windows_x86_64,
130 ]
131 }
132
133 pub fn into_jobs(self) -> Vec<NamedJob> {
134 vec![
135 self.linux_aarch64,
136 self.linux_x86_64,
137 self.mac_aarch64,
138 self.mac_x86_64,
139 self.windows_aarch64,
140 self.windows_x86_64,
141 ]
142 }
143}
144
145pub(crate) fn create_sentry_release() -> Step<Use> {
146 named::uses(
147 "getsentry",
148 "action-release",
149 "526942b68292201ac6bbb99b9a0747d4abee354c", // v3
150 )
151 .add_env(("SENTRY_ORG", "zed-dev"))
152 .add_env(("SENTRY_PROJECT", "zed"))
153 .add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN))
154 .add_with(("environment", "production"))
155}
156
157fn validate_release_assets(deps: &[&NamedJob]) -> NamedJob {
158 let expected_assets: Vec<String> = assets::all().iter().map(|a| format!("\"{a}\"")).collect();
159 let expected_assets_json = format!("[{}]", expected_assets.join(", "));
160
161 let validation_script = formatdoc! {r#"
162 EXPECTED_ASSETS='{expected_assets_json}'
163 TAG="$GITHUB_REF_NAME"
164
165 ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]')
166
167 MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]')
168
169 if [ -n "$MISSING_ASSETS" ]; then
170 echo "Error: The following assets are missing from the release:"
171 echo "$MISSING_ASSETS"
172 exit 1
173 fi
174
175 echo "All expected assets are present in the release."
176 "#,
177 };
178
179 named::job(
180 dependant_job(deps).runs_on(runners::LINUX_SMALL).add_step(
181 named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
182 ),
183 )
184}
185
186fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob {
187 let (authenticate, token) = steps::authenticate_as_zippy();
188
189 named::job(
190 dependant_job(deps)
191 .runs_on(runners::LINUX_SMALL)
192 .cond(Expression::new(indoc::indoc!(
193 r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
194 )))
195 .add_step(authenticate)
196 .add_step(
197 steps::script(
198 r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
199 )
200 .add_env(("GITHUB_TOKEN", &token)),
201 )
202 )
203}
204
205fn deploy_docs(deps: &[&NamedJob], create_draft_release: &NamedJob) -> NamedJob<UsesJob> {
206 let job = Job::default()
207 .needs(
208 deps.iter()
209 .map(|job| job.name.clone())
210 .chain(std::iter::once(create_draft_release.name.clone()))
211 .collect::<Vec<_>>(),
212 )
213 .permissions(Permissions::default().contents(Level::Read))
214 .uses(
215 "zed-industries",
216 "zed",
217 ".github/workflows/deploy_docs.yml",
218 "main",
219 )
220 .with(
221 Input::default()
222 .add(
223 "channel",
224 "${{ endsWith(github.ref_name, '-pre') && 'preview' || 'stable' }}",
225 )
226 .add("commit_sha", "${{ github.sha }}"),
227 )
228 .secrets(indexmap::IndexMap::from([
229 (
230 "DOCS_AMPLITUDE_API_KEY".to_owned(),
231 vars::DOCS_AMPLITUDE_API_KEY.to_owned(),
232 ),
233 (
234 "CLOUDFLARE_API_TOKEN".to_owned(),
235 vars::CLOUDFLARE_API_TOKEN.to_owned(),
236 ),
237 (
238 "CLOUDFLARE_ACCOUNT_ID".to_owned(),
239 vars::CLOUDFLARE_ACCOUNT_ID.to_owned(),
240 ),
241 ]));
242
243 NamedJob {
244 name: "deploy_docs".to_owned(),
245 job,
246 }
247}
248
249pub(crate) fn download_workflow_artifacts() -> Step<Use> {
250 named::uses(
251 "actions",
252 "download-artifact",
253 "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
254 )
255 .add_with(("path", "./artifacts/"))
256}
257
258pub(crate) fn prep_release_artifacts() -> Step<Run> {
259 let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()];
260 for asset in assets::all() {
261 let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}");
262 script_lines.push(mv_command)
263 }
264
265 named::bash(&script_lines.join("\n"))
266}
267
268fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob {
269 let mut deps = deps.to_vec();
270 deps.extend(bundle.jobs());
271
272 named::job(
273 dependant_job(&deps)
274 .runs_on(runners::LINUX_MEDIUM)
275 .add_step(download_workflow_artifacts())
276 .add_step(steps::script("ls -lR ./artifacts"))
277 .add_step(prep_release_artifacts())
278 .add_step(
279 steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*")
280 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
281 ),
282 )
283}
284
285fn create_draft_release() -> NamedJob {
286 fn generate_release_notes() -> Step<Run> {
287 named::bash(
288 r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#,
289 )
290 }
291
292 fn create_release() -> Step<Run> {
293 named::bash("script/create-draft-release target/release-notes.md")
294 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
295 }
296
297 named::job(
298 release_job(&[])
299 .runs_on(runners::LINUX_SMALL)
300 // We need to fetch more than one commit so that `script/draft-release-notes`
301 // is able to diff between the current and previous tag.
302 //
303 // 25 was chosen arbitrarily.
304 .add_step(
305 steps::checkout_repo()
306 .with_custom_fetch_depth(25)
307 .with_ref("${{ github.ref }}"),
308 )
309 .add_step(steps::script("script/determine-release-channel"))
310 .add_step(steps::script("mkdir -p target/"))
311 .add_step(generate_release_notes())
312 .add_step(create_release()),
313 )
314}
315
316pub(crate) fn push_release_update_notification(
317 create_draft_release_job: &NamedJob,
318 upload_assets_job: &NamedJob,
319 validate_assets_job: &NamedJob,
320 auto_release_preview: &NamedJob,
321 test_jobs: &[&NamedJob],
322 bundle_jobs: &ReleaseBundleJobs,
323) -> NamedJob {
324 let all_job_names = test_jobs
325 .into_iter()
326 .map(|j| j.name.as_ref())
327 .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()));
328
329 let notification_script = formatdoc! {r#"
330 DRAFT_RESULT="${{{{ needs.{draft_job}.result }}}}"
331 UPLOAD_RESULT="${{{{ needs.{upload_job}.result }}}}"
332 VALIDATE_RESULT="${{{{ needs.{validate_job}.result }}}}"
333 AUTO_RELEASE_RESULT="${{{{ needs.{auto_release_job}.result }}}}"
334 TAG="$GITHUB_REF_NAME"
335 RUN_URL="{run_url}"
336
337 if [ "$DRAFT_RESULT" == "failure" ]; then
338 echo "❌ Draft release creation failed for $TAG: $RUN_URL"
339 else
340 RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
341 if [ "$UPLOAD_RESULT" == "failure" ]; then
342 echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
343 elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
344 FAILED_JOBS=""
345 {failure_checks}
346 FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
347 if [ "$UPLOAD_RESULT" == "cancelled" ]; then
348 if [ -n "$FAILED_JOBS" ]; then
349 echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
350 else
351 echo "❌ Release job for $TAG was cancelled: $RUN_URL"
352 fi
353 else
354 if [ -n "$FAILED_JOBS" ]; then
355 echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
356 else
357 echo "❌ Tests for $TAG failed: $RUN_URL"
358 fi
359 fi
360 elif [ "$VALIDATE_RESULT" == "failure" ]; then
361 echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
362 elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
363 echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
364 elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
365 echo "❌ Auto release failed for $TAG: $RUN_URL"
366 else
367 echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
368 fi
369 fi
370 "#,
371 draft_job = create_draft_release_job.name,
372 upload_job = upload_assets_job.name,
373 validate_job = validate_assets_job.name,
374 auto_release_job = auto_release_preview.name,
375 run_url = CURRENT_ACTION_RUN_URL,
376 failure_checks = all_job_names
377 .into_iter()
378 .map(|name: &str| format!(
379 "if [ \"${{{{ needs.{name}.result }}}}\" == \"failure\" ];\
380 then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi"
381 ))
382 .collect::<Vec<_>>()
383 .join("\n "),
384 };
385
386 let mut all_deps: Vec<&NamedJob> = vec![
387 create_draft_release_job,
388 upload_assets_job,
389 validate_assets_job,
390 auto_release_preview,
391 ];
392 all_deps.extend(test_jobs.iter().copied());
393 all_deps.extend(bundle_jobs.jobs());
394
395 let mut job = dependant_job(&all_deps)
396 .runs_on(runners::LINUX_SMALL)
397 .cond(Expression::new("always()"));
398
399 for step in notify_slack(MessageType::Evaluated(notification_script)) {
400 job = job.add_step(step);
401 }
402 named::job(job)
403}
404
405pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
406 let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
407
408 let mut job = dependant_job(deps)
409 .runs_on(runners::LINUX_SMALL)
410 .cond(Expression::new("failure()"));
411
412 for step in notify_slack(MessageType::Static(failure_message)) {
413 job = job.add_step(step);
414 }
415 named::job(job)
416}
417
418pub(crate) enum MessageType {
419 Static(String),
420 Evaluated(String),
421}
422
423fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
424 match message {
425 MessageType::Static(message) => vec![send_slack_message(message)],
426 MessageType::Evaluated(expression) => {
427 let (generate_step, generated_message) = generate_slack_message(expression);
428
429 vec![
430 generate_step,
431 send_slack_message(generated_message.to_string()),
432 ]
433 }
434 }
435}
436
437fn generate_slack_message(expression: String) -> (Step<Run>, StepOutput) {
438 let script = formatdoc! {r#"
439 MESSAGE=$({expression})
440 echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
441 "#
442 };
443 let generate_step = named::bash(&script)
444 .id("generate-webhook-message")
445 .add_env(("GH_TOKEN", Context::github().token()));
446
447 let output = StepOutput::new(&generate_step, "message");
448
449 (generate_step, output)
450}
451
452fn send_slack_message(message: String) -> Step<Run> {
453 let script = formatdoc! {r#"
454 curl -X POST -H 'Content-type: application/json'\
455 --data '{{"text":"{message}"}}' "$SLACK_WEBHOOK"
456 "#
457 };
458 named::bash(&script).add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
459}